diff --git a/.env.example b/.env.example index a5de38d..1ad785e 100644 --- a/.env.example +++ b/.env.example @@ -22,9 +22,11 @@ BOOKINGS_DB_NAME=bookings BOOKINGS_DB_PORT=5434 # Начальная дата модельного времени для генерации демобазы BOOKINGS_START_DATE=2017-01-01 -# Количество дней для первой генерации (держим малым, чтобы быстрее увидеть данные) -BOOKINGS_INIT_DAYS=1 -# Количество джобов генератора. В учебном стенде поддерживается только значение 1. +# Количество дней для генерации с нуля (make bookings-generate); для make bookings-init не используется +BOOKINGS_INIT_DAYS=60 +# Количество параллельных джобов генератора (1 = синхронно без dblink, 2+ = через dblink). +# jobs=1 оптимален для инкрементальной генерации (+1 день ≈ 3 мин). +# jobs>1 на WSL2 в 3× медленнее из-за lock contention на gen.events. BOOKINGS_JOBS=1 # Greenplum Configuration @@ -43,7 +45,3 @@ GP_USE_AIRFLOW_CONN=true PXF_SEED_OVERWRITE=0 # PXF_SYNC_ON_START=1 — выполнять `pxf cluster sync` при старте контейнера (дольше, но гарантирует актуальные конфиги) PXF_SYNC_ON_START=0 - -# CSV Pipeline -CSV_DIR=/opt/airflow/data -CSV_ROWS=1000 diff --git a/AGENTS.md b/AGENTS.md index daff216..bd05319 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,10 +9,12 @@ - **Фокус на «Почему»:** При использовании специфичных паттернов DWH (например, `delete + insert` для инкремента в Greenplum вместо `merge`) — добавляйте краткий комментарий, объясняющий этот выбор студентам. ## 2. Карта проекта (Навигация для Агента) -- `airflow/dags/` — DAG-файлы (напр. `csv_to_greenplum.py`). +- `airflow/dags/` — DAG-файлы (напр. `bookings_to_gp_stage.py`). - `sql/` — DDL и SQL-скрипты. Разделены на слои: src/ (исходные системы), stg/ (стейджинг), ods/ (операционное хранилище), dds/ (детальное хранилище), dm/ (слой витрин). - *Правило ИИ:* DDL таблиц хранится строго рядом с объектом (напр. `sql/stg/bookings_ddl.sql`). -- `docs/internal/naming_conventions.md` — Единый источник истины для нейминга служебных и SCD-полей. *Правило ИИ: Всегда сверяться с этим файлом при генерации новых DDL/SQL.* +- `docs/` — документация. Подкаталоги: `design/` (дизайн, стандарты), `reference/` (справочники), `plans/` (активные планы), `archive/` (выполненные планы), `assignment/` (задание для студента). + - `docs/design/naming_conventions.md` — единый источник нейминга служебных и SCD-полей. *Правило ИИ: всегда сверяться при генерации DDL/SQL.* + - Нейминг файлов планов: `YYYY-MM-DD_краткое-описание.md` (дата — ISO 8601, `_` отделяет дату от описания, описание в kebab-case, без суффикса `-plan`). - `tests/` — pytest-тесты (smoke-тесты DAG'ов и юнит-тесты). - `.env` — Настройки окружения (все секреты `GP_*`, `AIRFLOW_*` берем только отсюда). @@ -35,7 +37,44 @@ - **Код:** Переменные, функции, SQL-идентификаторы (`snake_case`) — на английском. - **Коммиты:** Придерживайтесь Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`). Если меняется логика DAG'а, в описании PR (или коммита) указывайте, что именно изменилось. -## 6. Порядок работы Агента -1. Держите изменения минимальными и локальными. Избегайте глобальных рефакторингов. -2. Перед завершением задачи обязательно валидируйте код: выполните `uv run make fmt` и `uv run make test`. -3. При изменении схемы БД — обязательно обновите `sql/ddl_gp.sql`. +### Структура и нейминг SQL (слои DWH) +- В каталоге `sql/` придерживаемся слоёв DWH: + - `sql/src/` — скрипты, работающие с исходными системами (например, `bookings_generate_day_if_missing.sql`); + - `sql/stg/` — скрипты для стейджинга (`bookings_ddl.sql`, `bookings_load.sql`, `bookings_dq.sql`); + - `sql/ods/` — скрипты ODS (операционное хранилище); + - `sql/dds/` — скрипты DDS (детальное хранилище, star schema); + - `sql/dm/` — скрипты DM (витрины / data marts). +- Нейминг служебных полей и SCD-полей фиксирован в `docs/design/naming_conventions.md` (единый источник для всех новых слоёв). +- Именование файлов: `{объект}_{роль}.sql`, где: + - `объект` — логическое имя сущности (`bookings`, `orders`, и т.п.); + - `роль` — `ddl` (создание/изменение объектов), `load` (загрузка/инкремент), `dq` (проверки качества данных) и т.п. +- Общие DDL-скрипты (например, `sql/ddl_gp.sql`) могут подключать файловые DDL через `\i`, но сами определения таблиц живут рядом с объектом (`sql/stg/bookings_ddl.sql` и т.п.). + +### Airflow + SQL +- В учебных DAG’ах, где основная логика — в SQL, по умолчанию используем `PostgresOperator` + Airflow Connections: + - DAG оркестрирует шаги и подключение к БД; + - SQL-скрипты лежат в `sql/...` и подключаются по пути (`sql='sql/stg/bookings_load.sql'`). +- Сложную ручную работу с подключениями (`psycopg2`, ENV-фоллбеки) используем только там, где реально много Python-логики и это помогает учебной цели. + +## Тестирование +- Тесты лежат в `tests/` (pytest). Запуск: `make test`. +- Есть 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`. +- Держите изменения минимальными и локальными. Не переименовывайте Make‑таргеты без обновления документации. +- В описании PR добавляйте скрин DAG‑графа или логи задач, если менялась логика. +- При изменении схемы/поведения — обновляйте `README.md` и `sql/ddl_gp.sql`. + +## Безопасность и конфигурация +- Все настройки — через `.env`; креды в коде не хардкодим. Частые переменные: `GP_*`, `PG_*`, `AIRFLOW_*`. +- `make clean` удаляет тома — предупреждайте студентов, что данные пропадут. + +## Для агента (особенности аудитории) +- Пишите простыми словами. Добавляйте короткие комментарии к нетривиальной логике. +- Избегайте больших рефакторингов и сложных паттернов — студенты только начинают. +- Ошибки и логи — дружелюбные и понятные (лучше с подсказкой «что сделать дальше»). +- Перед релевантными правками валидируйте локально: `make up`, затем откройте DAG в UI и/или прогоните `make test`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Makefile b/Makefile index 858994b..bd671c1 100644 --- a/Makefile +++ b/Makefile @@ -2,14 +2,16 @@ SHELL := /bin/bash UV := uv PYTHON_VERSION := 3.11 DEMODB_REPO := https://github.com/postgrespro/demodb.git -DEMODB_COMMIT := d68de192850237719f09b47688d5f3fc94653ca6 +DEMODB_COMMIT := 866e56f7fe54596a1d2a88f5f32f4aa3b2698121 BOOKINGS_JOBS ?= 1 BOOKINGS_START_DATE ?= 2017-01-01 -BOOKINGS_INIT_DAYS ?= 1 +BOOKINGS_INIT_DAYS ?= 60 .PHONY: up stop down clean airflow-init logs gp-psql ddl-gp \ - bookings-check-jobs bookings-clone-demodb bookings-init bookings-psql bookings-generate-day \ - dev-setup dev-sync dev-lock test lint fmt clean-venv build + bookings-check-jobs bookings-clone-demodb bookings-init bookings-generate bookings-dump \ + bookings-psql bookings-generate-day \ + dev-setup dev-sync dev-lock test lint fmt clean-venv build e2e-smoke e2e-etl +BOOKINGS_SEED := bookings/seed/demo.sql.xz SHELL := /bin/bash up: @@ -36,6 +38,9 @@ logs: gp-psql: docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -p 5432 -d gp_dwh'" +dwh-truncate: + docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c 'cd /sql && /usr/local/greenplum-db/bin/psql -d gp_dwh -f truncate_gp.sql'" + ddl-gp: docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c 'cd /sql && /usr/local/greenplum-db/bin/psql -d gp_dwh -f ddl_gp.sql'" @@ -49,25 +54,35 @@ bookings-clone-demodb: # Патчим generate/continue: при jobs=1 запускаем process_queue синхронно, без dblink if ! grep -q "Job 1 (local): ok" bookings/demodb/engine.sql; then \ if ! patch -d bookings/demodb -p1 --forward < bookings/patches/engine_jobs1_sync.patch; then \ - echo "Не удалось применить патч engine_jobs1_sync.patch. Удалите bookings/demodb и повторите make bookings-init." >&2; \ + echo "Не удалось применить патч engine_jobs1_sync.patch. Удалите bookings/demodb и повторите make bookings-generate." >&2; \ exit 1; \ fi; \ fi # Делаем установку идемпотентной и принудительной: DROP DATABASE IF EXISTS demo WITH (FORCE) if ! grep -q "DROP DATABASE IF EXISTS demo WITH (FORCE);" bookings/demodb/install.sql; then \ if ! patch -d bookings/demodb -p1 --forward < bookings/patches/install_drop_if_exists.patch; then \ - echo "Не удалось применить патч install_drop_if_exists.patch. Удалите bookings/demodb и повторите make bookings-init." >&2; \ + echo "Не удалось применить патч install_drop_if_exists.patch. Удалите bookings/demodb и повторите make bookings-generate." >&2; \ + exit 1; \ + fi; \ + fi + # Убираем хардкод gen.connstr без credentials — иначе VACUUM падает + if grep -q "gen.connstr = 'dbname=demo'" bookings/demodb/install.sql; then \ + if ! patch -d bookings/demodb -p1 --forward < bookings/patches/install_connstr_no_hardcode.patch; then \ + echo "Не удалось применить патч install_connstr_no_hardcode.patch. Удалите bookings/demodb и повторите make bookings-generate." >&2; \ exit 1; \ fi; \ fi bookings-check-jobs: - @if [ "$(BOOKINGS_JOBS)" != "1" ]; then \ - echo "Поддерживается только BOOKINGS_JOBS=1. Измените .env и повторите команду." >&2; \ + @case "$(BOOKINGS_JOBS)" in \ + ''|*[!0-9]*) echo "BOOKINGS_JOBS должен быть целым числом >= 1. Текущее значение: '$(BOOKINGS_JOBS)'." >&2; exit 1;; \ + esac; \ + if [ "$(BOOKINGS_JOBS)" -lt 1 ]; then \ + echo "BOOKINGS_JOBS должен быть >= 1. Текущее значение: $(BOOKINGS_JOBS)." >&2; \ exit 1; \ fi -bookings-init: bookings-check-jobs bookings-clone-demodb +bookings-generate: bookings-check-jobs bookings-clone-demodb docker compose -f docker-compose.yml up -d bookings-db docker compose -f docker-compose.yml exec bookings-db bash -lc '\ until PGPASSWORD="$$POSTGRES_PASSWORD" pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -h localhost; do \ @@ -86,10 +101,96 @@ bookings-init: bookings-check-jobs bookings-clone-demodb ALTER DATABASE demo SET bookings.init_days = '\''$(BOOKINGS_INIT_DAYS)'\''; \ ALTER DATABASE demo SET bookings.jobs = '\''$(BOOKINGS_JOBS)'\'';" \ ' - # Генерируем первый день данных, чтобы база не оставалась пустой + # Генерируем данные за $(BOOKINGS_INIT_DAYS) дней docker compose -f docker-compose.yml exec bookings-db bash -lc '\ PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d demo -f /bookings/generate_next_day.sql \ ' + # Валидация: ключевые таблицы не должны быть пустыми + @docker compose -f docker-compose.yml exec bookings-db bash -lc '\ + PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d demo -tAc " \ + SELECT format(E'\''%-25s %s'\'', t, cnt) \ + FROM ( \ + SELECT '\''bookings.bookings'\'' AS t, count(*) AS cnt FROM bookings.bookings \ + UNION ALL \ + SELECT '\''bookings.tickets'\'', count(*) FROM bookings.tickets \ + UNION ALL \ + SELECT '\''bookings.flights'\'', count(*) FROM bookings.flights \ + UNION ALL \ + SELECT '\''bookings.boarding_passes'\'', count(*) FROM bookings.boarding_passes \ + ) s ORDER BY t; \ + " \ + ' + @docker compose -f docker-compose.yml exec bookings-db bash -lc '\ + PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d demo -tAc " \ + DO \$$\$$ \ + DECLARE v_cnt bigint; \ + BEGIN \ + SELECT count(*) INTO v_cnt FROM bookings.bookings; \ + IF v_cnt = 0 THEN \ + RAISE EXCEPTION '\''bookings.bookings пуста — генерация не сработала. Проверьте логи: SELECT * FROM gen.log ORDER BY at DESC LIMIT 10;'\''; \ + END IF; \ + SELECT count(*) INTO v_cnt FROM bookings.flights; \ + IF v_cnt = 0 THEN \ + RAISE EXCEPTION '\''bookings.flights пуста — попробуйте увеличить BOOKINGS_INIT_DAYS.'\''; \ + END IF; \ + END \$$\$$; \ + " \ + ' + +bookings-init: bookings-check-jobs + @if [ ! -f $(BOOKINGS_SEED) ]; then \ + echo "Файл $(BOOKINGS_SEED) не найден. Используйте make bookings-generate для генерации с нуля." >&2; \ + exit 1; \ + fi + docker compose -f docker-compose.yml up -d bookings-db + docker compose -f docker-compose.yml exec bookings-db bash -lc '\ + until PGPASSWORD="$$POSTGRES_PASSWORD" pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -h localhost; do \ + echo "Waiting for bookings-db to become ready..."; \ + sleep 1; \ + done \ + ' + docker compose -f docker-compose.yml exec bookings-db bash -lc '\ + PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" \ + -c "DROP DATABASE IF EXISTS demo WITH (FORCE);" \ + ' + @echo "Восстановление из дампа (~42 MB)..." + xz -dc $(BOOKINGS_SEED) | docker compose -f docker-compose.yml exec -T bookings-db bash -lc '\ + PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" \ + ' + # Применяем настройки из текущего окружения поверх дампа + docker compose -f docker-compose.yml exec bookings-db bash -lc '\ + CONNSTR="dbname=demo user=$$POSTGRES_USER password=$$POSTGRES_PASSWORD"; \ + PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -c "\ + ALTER DATABASE demo SET gen.connstr='\''$$CONNSTR'\''; \ + ALTER DATABASE demo SET bookings.start_date = '\''$(BOOKINGS_START_DATE)'\''; \ + ALTER DATABASE demo SET bookings.init_days = '\''$(BOOKINGS_INIT_DAYS)'\''; \ + ALTER DATABASE demo SET bookings.jobs = '\''$(BOOKINGS_JOBS)'\'';" \ + ' + @echo "Восстановление завершено. Проверяем данные..." + @docker compose -f docker-compose.yml exec bookings-db bash -lc '\ + PGPASSWORD="$$POSTGRES_PASSWORD" psql -U "$$POSTGRES_USER" -d demo -tAc " \ + SELECT format(E'\''%-25s %s'\'', t, cnt) \ + FROM ( \ + SELECT '\''bookings.bookings'\'' AS t, count(*) AS cnt FROM bookings.bookings \ + UNION ALL \ + SELECT '\''bookings.tickets'\'', count(*) FROM bookings.tickets \ + UNION ALL \ + SELECT '\''bookings.flights'\'', count(*) FROM bookings.flights \ + UNION ALL \ + SELECT '\''bookings.boarding_passes'\'', count(*) FROM bookings.boarding_passes \ + ) s ORDER BY t; \ + " \ + ' + +bookings-dump: + @echo "Создание дампа demo → $(BOOKINGS_SEED)..." + mkdir -p bookings/seed + docker compose -f docker-compose.yml exec bookings-db bash -lc '\ + PGPASSWORD="$$POSTGRES_PASSWORD" pg_dump -U "$$POSTGRES_USER" -d demo --format=plain --create \ + ' > /tmp/demo_dump_$$$$.sql + xz -9 < /tmp/demo_dump_$$$$.sql > $(BOOKINGS_SEED) + rm -f /tmp/demo_dump_$$$$.sql + @echo "Дамп сохранён: $$(ls -lh $(BOOKINGS_SEED) | awk '{print $$5}')" bookings-psql: docker compose -f docker-compose.yml exec bookings-db bash -lc 'PGPASSWORD="$$POSTGRES_PASSWORD" psql -U "$$POSTGRES_USER" -d demo' @@ -133,3 +234,7 @@ clean-venv: e2e-smoke: ./scripts/e2e_smoke.sh + +e2e-etl: + ./scripts/e2e_etl.sh + diff --git a/README.md b/README.md index a59de83..fd8ebf9 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ - построения ETL/ELT; - работы с Airflow и Greenplum. -В курсовой у нас один источник данных — демо‑БД **bookings**. В стенде уже есть готовый учебный пример -загрузки **bookings → stg в Greenplum**, чтобы вы могли сфокусироваться на DWH‑части (ODS/DDS/DM) и -не тратить время на инфраструктуру. +В курсовой у нас один источник данных — демо‑БД **bookings**. В стенде уже есть готовые учебные примеры +загрузки **bookings → STG → ODS → DDS → DM** в Greenplum, чтобы вы могли сфокусироваться +на DWH‑части и не тратить время на инфраструктуру. ## Что внутри @@ -22,7 +22,6 @@ - **Greenplum** (single‑node для обучения; внешний порт по умолчанию `5435`) - **bookings-db** (Postgres с демо‑БД `demo`; внешний порт по умолчанию `5434`) - **PXF** как “транспорт” между Postgres и Greenplum (уже настроен в образе) -- Побочный пример: загрузка данных через **pandas/CSV** (`csv_to_greenplum`) ## Требования @@ -44,7 +43,7 @@ Про PXF и технические детали стенда: [docs/stack.md](docs/stack.md). -## Быстрый старт (основной сценарий: bookings → stg) +## Быстрый старт (основной сценарий: bookings → STG → ODS → DDS → DM) 1) Скопируйте настройки: @@ -62,17 +61,21 @@ make up 3) Инициализируйте демо‑БД bookings: ```bash -make bookings-init +make bookings-init # быстрое восстановление из seed-дампа (~18 сек), рекомендуется +# make bookings-generate # альтернатива: полная генерация с нуля (занимает часы) ``` -Важно: генератор `bookings` в этом стенде поддерживается только в режиме `BOOKINGS_JOBS=1`. +4) Подготовьте объекты DWH в Greenplum (выберите один вариант): -4) Подготовьте STG‑объекты в Greenplum (выберите один вариант): +- Учебный вариант: в Airflow UI запустите DAG'и по порядку — `bookings_stg_ddl`, `bookings_ods_ddl`, `bookings_dds_ddl`, `bookings_dm_ddl`; +- Технический шорткат: `make ddl-gp` (применяет DDL для STG, ODS, DDS и DM разом). -- Учебный вариант: в Airflow UI запустите DAG `bookings_stg_ddl`; -- Технический шорткат: `make ddl-gp` (применяет все DDL разом вручную). +5) Запустите DAG'и загрузки данных по порядку: -5) Запустите основной DAG `bookings_to_gp_stage`. +- `bookings_to_gp_stage` — загрузка в STG +- `bookings_to_gp_ods` — загрузка в ODS +- `bookings_to_gp_dds` — загрузка в DDS +- `bookings_to_gp_dm` — загрузка в DM (витрины) 6) Проверьте результат в Greenplum: @@ -80,7 +83,14 @@ make bookings-init make gp-psql -- внутри psql: SELECT COUNT(*) FROM stg.bookings; -SELECT * FROM stg.bookings ORDER BY src_created_at_ts DESC LIMIT 10; +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.fact_flight_sales; +SELECT COUNT(*) FROM dm.sales_report; +SELECT COUNT(*) FROM dm.route_performance; ``` Подробнее про логику DAG и проверки — `docs/bookings_to_gp_stage.md`. @@ -89,15 +99,15 @@ SELECT * FROM stg.bookings ORDER BY src_created_at_ts DESC LIMIT 10; Основные (для потока bookings → DWH): -- `bookings_stg_ddl` — создаёт `stg.bookings_ext` и `stg.bookings` в Greenplum; -- `bookings_to_gp_stage` — генерирует учебный день в `bookings-db` и грузит инкремент в `stg.bookings` - (через PXF), затем выполняет DQ‑проверку. - -Вспомогательные (побочный трек с CSV): - -- `orders_base_ddl` — создаёт таблицу `public.orders` для CSV‑пайплайна; -- `csv_to_greenplum` — pandas → CSV → Greenplum (пример загрузки без источника‑БД); -- `csv_to_greenplum_dq` — проверки качества данных для `public.orders`. +- `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‑проверки. +- `bookings_ods_ddl` — создаёт/обновляет ODS-таблицы по домену bookings. +- `bookings_to_gp_ods` — загружает данные из STG в ODS (SCD1 UPSERT) и выполняет 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). +- `bookings_to_gp_dm` — загружает данные из DDS в DM (агрегированные витрины). ## Полезные команды @@ -106,7 +116,7 @@ make up # поднять стек make logs # логи airflow-webserver и airflow-scheduler make gp-psql # psql в Greenplum make bookings-psql # psql в демо-БД bookings (Postgres) -make ddl-gp # применить DDL к Greenplum вручную (вместо DDL-DAG) +make ddl-gp # применить DDL STG+ODS+DDS+DM к Greenplum вручную (вместо DDL-DAG) make down # остановить и удалить контейнеры/сети (volumes сохраняются) make clean # полный reset: удалить контейнеры/сети и volumes (данные будут потеряны) ``` @@ -133,17 +143,21 @@ make clean # полный reset: удалить контейнер ## Документация -- Учебные задания: `educational-tasks.md`). -- План тестирования/проверок и негативные кейсы: `TESTING.md`. -- Дополнительные заметки и технические детали: `docs/README.md`. +- [Учебные задания](docs/assignment/README.md) +- [План тестирования/проверок и негативные кейсы](TESTING.md) +- [Дополнительные заметки и технические детали](docs/README.md) +- [Детали по STG DAG](docs/bookings_to_gp_stage.md) +- [Детали по ODS DAG](docs/bookings_to_gp_ods.md) +- [Детали по DDS DAG](docs/bookings_to_gp_dds.md) +- [Детали по DM DAG](docs/bookings_to_gp_dm.md) ## Типичные проблемы и решения | Проблема | Решение | |----------|---------| | Airflow UI не открывается | Дождитесь сообщения `Listening at: http://0.0.0.0:8080` в логах (`make logs`) | -| `database "demo" does not exist` в bookings‑DAG | Вы сделали reset с удалением volumes (`make clean` / `docker compose down -v`). Запустите `make bookings-init` и повторите DAG. | -| `Поддерживается только bookings.jobs=1` или `BOOKINGS_JOBS=1` | В этом учебном стенде поддерживается только синхронный режим генерации. Установите `BOOKINGS_JOBS=1` и выполните `make bookings-init`. | +| `database "demo" does not exist` в bookings‑DAG | Вы сделали reset с удалением volumes (`make clean` / `docker compose down -v`). Запустите `make bookings-init` (быстрое восстановление из дампа, ~18 сек) и повторите DAG. | +| Ошибка `bookings.jobs должен быть >= 1` | Проверьте значение `BOOKINGS_JOBS` в `.env` — оно должно быть целым числом >= 1. По умолчанию `BOOKINGS_JOBS=1` (синхронная генерация). При `BOOKINGS_JOBS>1` генерация идёт параллельно через dblink. | | Ошибка подключения к Greenplum | Убедитесь, что контейнер `greenplum` имеет статус `healthy` (`docker compose ps`) | | `protocol "pxf" does not exist` | Перезапустите `greenplum` и повторите `bookings_stg_ddl`/`make ddl-gp` — расширение `pxf` создаётся автоматически при старте контейнера. | | DAG `bookings_to_gp_stage` ругается на отсутствующие таблицы stg | Запустите `bookings_stg_ddl` (или выполните `make ddl-gp`), затем повторите запуск | diff --git a/TESTING.md b/TESTING.md index 35fd5a3..e2d3c70 100644 --- a/TESTING.md +++ b/TESTING.md @@ -28,21 +28,18 @@ 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` внутри контейнера должны отрабатывать без ошибок. -3. DAG `csv_to_greenplum`: - - Включить переключатель. - - Нажать «Trigger DAG». - - Контроль: все таски Success, в `data/` появился CSV, в логах `load_csv_to_greenplum` видно `INSERT`. - - В Greenplum (см. п.5) убедиться в наличии строк `(SELECT COUNT(*) ...)`. -4. DAG `csv_to_greenplum_dq`: - - Запустить вручную после первого DAG. - - Проверить, что все 5 задач Success и логи содержат `Проверка пройдена`. - DAG `bookings_to_gp_stage` (полная проверка цепочки bookings → Greenplum STG): - - предварительно выполнить один раз: `make bookings-init` (установка демобазы `demo` в контейнере `bookings-db`) и `make ddl-gp` (создаёт `stg.bookings_ext` и `stg.bookings` в Greenplum); - - важно: DAG `bookings_stg_ddl` **не** создаёт базу `demo` в `bookings-db`; если вы делали `docker compose down -v` / `make clean`, `make bookings-init` обязателен; + - предварительно выполнить один раз: `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`; - - убедиться, что все задачи (`generate_bookings_day`, `load_bookings_to_stg`, `check_row_counts`, `finish_summary`) завершились со статусом Success; - - при желании проверить данные: в `bookings-db` появился новый день, а в Greenplum в `stg.bookings` — строки с актуальным `batch_id` (см. пример запросов в разделе 5). + - убедиться, что все задачи завершились со статусом Success (включая загрузки справочников/транзакций и DQ); + - при желании проверить данные: в `bookings-db` появился новый день, а в Greenplum в `stg.bookings` — строки с актуальным `_load_id` (см. пример запросов в разделе 5). - (опционально, для менторов/разработчиков) 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» режиме; @@ -54,18 +51,13 @@ - `docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/pxf/bin/pxf cluster status'"` - Команды внутри psql: - `\dt public.*` — таблицы схему public. - - `SELECT COUNT(*) FROM public.orders;` — оценка объёма. - - `SELECT * FROM public.orders LIMIT 5;` — визуальная проверка. - - `SELECT order_id FROM public.orders GROUP BY 1 HAVING COUNT(*) > 1;` — поиск дублей. - (после настройки PXF) `SELECT COUNT(*) FROM public.ext_bookings_bookings;` — проверка чтения из демо-БД bookings через PXF. - (после настройки PXF) `SELECT * FROM public.ext_bookings_bookings LIMIT 5;` — визуальное сравнение с таблицей `bookings.bookings` в исходной БД. - Завершить `\q`. ## 6. Негативные сценарии и fallback -- **Пустая таблица**: запустить `csv_to_greenplum_dq` до `csv_to_greenplum`. Ожидается ошибка на таске `check_orders_has_rows`. - **Проблемы с подключением**: временно изменить `GP_HOST` или `GP_PORT` на несуществующий, перезапустить `make up`, убедиться, что DAG падает с понятной ошибкой (`psycopg2.OperationalError`). - **Fallback без Airflow Connection**: установить `GP_USE_AIRFLOW_CONN=false`, перезапустить стек (`make down && make up`), удостовериться, что загрузка и DQ работают через ENV. -- **Дубликаты**: дважды вызвать `csv_to_greenplum` — ожидаем, что количество строк в `public.orders` не увеличится на размер CSV, а DAG `csv_to_greenplum_dq` не найдёт дублей. - **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`) и убедиться, что запрос снова работает. ## 7. Быстрый reset (если «что-то сломалось») @@ -77,14 +69,12 @@ ## 8. Снятие метрик и мониторинг - Контейнеры: `docker compose ps`, `docker stats` (по желанию). - Логи задач: в Airflow UI → конкретный таск → Log. -- Хостовые CSV: каталог `data/` (можно открыть любой файл и убедиться в структуре). ## 9. Завершение работы - `make down` — выключает сервисы и удаляет контейнеры/сети (volumes сохраняются). - Полный сброс данных (удаляет volumes): `make clean`. -- При необходимости сохранить данные: скопировать CSV из `data/` и сделать дампы до `make clean`. ## Текущий статус (пример успешного прогона) -- `uv run pytest -q` — 11 passed, 2 smoke-теста DAG пропущены (Airflow не установлен в venv). +- `uv run pytest -q` — 14 passed, 9 smoke-тестов DAG пропущены (Airflow не установлен в venv). - `make lint` — проходит (DAG‑файлы отформатированы black/isort). -- Docker-стенд не запускался в рамках этой сессии; ожидается, что инструкции выше обеспечат полноценную проверку. +- Полный ETL-цикл (STG→ODS→DDS→DM) проверен на стенде 2026-03-09: все DAG-и завершились с Success. diff --git a/TODO.md b/TODO.md index 4d096f0..c19f08e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,21 +1,102 @@ # TODO (maintainers / mentors) -Этот файл собирает идеи по доработке стенда, которые не критичны для текущих задач менти, -но улучшат стабильность и удобство сопровождения. +Этот файл собирает задачи по подготовке стенда к курсовой работе +и идеи по доработке, которые не критичны для текущих задач менти. -Статус: ниже есть как актуальные, так и уже выполненные пункты. +Контекст и стратегия: [docs/design/PRD.md](docs/design/PRD.md). +Дизайн задания: [docs/design/assignment_design.md](docs/design/assignment_design.md). -- [ ] Сделать REST API Airflow основным способом тестирования ETL вместо CLI-вызовов - через `docker compose exec ... airflow ...`: - - обновить `TESTING.md`, сместив фокус на REST API сценарии; - - оставить CLI как резервный вариант для локальной отладки; - - проверить, что шаги тестирования воспроизводимы без входа в контейнер Airflow. +--- + +## Подготовка курсовой + +> Дедлайн: ~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`; @@ -27,9 +108,9 @@ для docker‑стенда: - проверить, какие параметры достаточно поменять в env/конфиге (`AIRFLOW__CORE__EXECUTOR`) для образа `apache/airflow:2.9.2`; - - убедиться, что примерные DAG’и (`csv_to_greenplum`, `bookings_to_gp_stage`) ведут себя + - убедиться, что примерные DAG'и (`csv_to_greenplum`, `bookings_to_gp_stage`) ведут себя предсказуемо в режиме параллельного исполнения; - - при необходимости скорректировать тесты и документацию (README/TESTING) с учётом нового executor’а. + - при необходимости скорректировать тесты и документацию (README/TESTING) с учётом нового executor'а. - [x] Разобрать и стабилизировать интеграцию с Greenplum/PXF: - убедиться, что PXF в контейнере `greenplum` всегда корректно инициализируется @@ -37,14 +118,14 @@ - при необходимости доработать init‑скрипты в `pxf/init/` и/или документацию, чтобы порядок действий для ментей был однозначным и воспроизводимым; - добавить краткий раздел в README/TESTING о типичных ошибках PXF/Greenplum и шагах по их устранению. - - диагностика текущего кейса: `docs/internal/pxf_bookings.md` (раздел «Известная проблема»). + - диагностика текущего кейса: `docs/reference/pxf_bookings.md` (раздел «Известная проблема»). - [x] Разобраться с генератором demodb: - - после `make bookings-init` таблица `bookings.bookings` остаётся пустой; + - после `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` валится на проверках (источник пустой). - - план: `plans/bookings-demodb-bugfix-plan.md` + - детали: `docs/reference/bookings_db_issues.md` - [x] Добавить раздел «Благодарности» в `README.md`: - явно поблагодарить Postgres Pro за демо‑БД bookings (репозиторий `postgrespro/demodb`); @@ -52,3 +133,9 @@ - при необходимости сослаться на соответствующие лицензии/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`. diff --git a/_vendor/demodb b/_vendor/demodb new file mode 160000 index 0000000..d68de19 --- /dev/null +++ b/_vendor/demodb @@ -0,0 +1 @@ +Subproject commit d68de192850237719f09b47688d5f3fc94653ca6 diff --git a/airflow/dags/bookings_dds_ddl.py b/airflow/dags/bookings_dds_ddl.py new file mode 100644 index 0000000..93f9d9a --- /dev/null +++ b/airflow/dags/bookings_dds_ddl.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +""" +Учебный DAG: создаёт/обновляет слой dds в Greenplum для домена bookings. + +Запускается вручную перед DAG загрузки `bookings_to_gp_dds` или после изменения DDS DDL. +Создаёт 7 DDS-таблиц: dim_calendar, dim_airports, dim_airplanes, +dim_tariffs, dim_passengers, dim_routes, fact_flight_sales. +""" + +from datetime import timedelta + +import pendulum +from airflow.providers.postgres.operators.postgres import PostgresOperator + +from airflow import DAG + +GREENPLUM_CONN_ID = "greenplum_conn" + +default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)} + +with DAG( + dag_id="bookings_dds_ddl", + start_date=pendulum.datetime(2017, 1, 1, tz="UTC"), + schedule=None, + catchup=False, + template_searchpath="/sql", + default_args=default_args, + tags=["demo", "greenplum", "ddl", "bookings", "dds"], + description="Учебный DDL DAG: создаёт/обновляет dds.* для bookings", +) as dag: + apply_dds_dim_calendar_ddl = PostgresOperator( + task_id="apply_dds_dim_calendar_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_calendar_ddl.sql", + ) + + apply_dds_dim_airports_ddl = PostgresOperator( + task_id="apply_dds_dim_airports_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_airports_ddl.sql", + ) + + apply_dds_dim_airplanes_ddl = PostgresOperator( + task_id="apply_dds_dim_airplanes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_airplanes_ddl.sql", + ) + + apply_dds_dim_tariffs_ddl = PostgresOperator( + task_id="apply_dds_dim_tariffs_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_tariffs_ddl.sql", + ) + + apply_dds_dim_passengers_ddl = PostgresOperator( + task_id="apply_dds_dim_passengers_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_passengers_ddl.sql", + ) + + apply_dds_dim_routes_ddl = PostgresOperator( + task_id="apply_dds_dim_routes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_routes_ddl.sql", + ) + + apply_dds_fact_flight_sales_ddl = PostgresOperator( + task_id="apply_dds_fact_flight_sales_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/fact_flight_sales_ddl.sql", + ) + + # DDL применяем последовательно, чтобы порядок был понятным для новичков, + # а ошибки — воспроизводимыми (в логах сразу видно, на каком объекте упали). + ( + apply_dds_dim_calendar_ddl + >> apply_dds_dim_airports_ddl + >> apply_dds_dim_airplanes_ddl + >> apply_dds_dim_tariffs_ddl + >> apply_dds_dim_passengers_ddl + >> apply_dds_dim_routes_ddl + >> apply_dds_fact_flight_sales_ddl + ) diff --git a/airflow/dags/bookings_dm_ddl.py b/airflow/dags/bookings_dm_ddl.py new file mode 100644 index 0000000..6af5223 --- /dev/null +++ b/airflow/dags/bookings_dm_ddl.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +""" +Учебный DAG: создаёт/обновляет DM-слой (Data Mart) в Greenplum для домена bookings. + +Запускается вручную перед DAG загрузки `bookings_to_gp_dm` или после изменения DM DDL. +Создаёт 5 DM-витрин: sales_report, route_performance, passenger_loyalty, +airport_traffic, monthly_overview. + +На данном этапе реализованы все 5 витрин: sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview. +""" + +from datetime import timedelta + +import pendulum +from airflow.providers.postgres.operators.postgres import PostgresOperator + +from airflow import DAG + +GREENPLUM_CONN_ID = "greenplum_conn" + +default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)} + +with DAG( + dag_id="bookings_dm_ddl", + start_date=pendulum.datetime(2017, 1, 1, tz="UTC"), + schedule=None, + catchup=False, + template_searchpath="/sql", + default_args=default_args, + tags=["demo", "greenplum", "ddl", "bookings", "dm"], + description="Учебный DDL DAG: создаёт/обновляет dm.* для bookings", +) as dag: + # Эталонная витрина: sales_report + apply_dm_sales_report_ddl = PostgresOperator( + task_id="apply_dm_sales_report_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/sales_report_ddl.sql", + ) + + # Витрина: Эффективность маршрутов (Этап 2) + apply_dm_route_performance_ddl = PostgresOperator( + task_id="apply_dm_route_performance_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/route_performance_ddl.sql", + ) + + # Витрина: Лояльность пассажиров (Этап 3) + apply_dm_passenger_loyalty_ddl = PostgresOperator( + task_id="apply_dm_passenger_loyalty_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/passenger_loyalty_ddl.sql", + ) + + # Витрина: Пассажиропоток аэропортов (Этап 4) + apply_dm_airport_traffic_ddl = PostgresOperator( + task_id="apply_dm_airport_traffic_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/airport_traffic_ddl.sql", + ) + + # Витрина: Помесячная сводка (Этап 5) + apply_dm_monthly_overview_ddl = PostgresOperator( + task_id="apply_dm_monthly_overview_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/monthly_overview_ddl.sql", + ) + + # Линейная цепочка + ( + apply_dm_sales_report_ddl + >> apply_dm_route_performance_ddl + >> apply_dm_passenger_loyalty_ddl + >> apply_dm_airport_traffic_ddl + >> apply_dm_monthly_overview_ddl + ) diff --git a/airflow/dags/bookings_ods_ddl.py b/airflow/dags/bookings_ods_ddl.py new file mode 100644 index 0000000..eb226b9 --- /dev/null +++ b/airflow/dags/bookings_ods_ddl.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +""" +Учебный DAG: создаёт/обновляет слой ods в Greenplum для домена bookings. + +Запускается вручную перед DAG загрузки `bookings_to_gp_ods` или после изменения ODS DDL. +Создаёт 9 ODS-таблиц: airports, airplanes, routes, seats, bookings, tickets, +flights, segments, boarding_passes. +""" + +from datetime import timedelta + +import pendulum +from airflow.providers.postgres.operators.postgres import PostgresOperator + +from airflow import DAG + +GREENPLUM_CONN_ID = "greenplum_conn" + +default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)} + +with DAG( + dag_id="bookings_ods_ddl", + start_date=pendulum.datetime(2017, 1, 1, tz="UTC"), + schedule=None, + catchup=False, + template_searchpath="/sql", + default_args=default_args, + tags=["demo", "greenplum", "ddl", "bookings", "ods"], + description="Учебный DDL DAG: создаёт/обновляет ods.* для bookings", +) as dag: + apply_ods_airports_ddl = PostgresOperator( + task_id="apply_ods_airports_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/airports_ddl.sql", + ) + + apply_ods_airplanes_ddl = PostgresOperator( + task_id="apply_ods_airplanes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/airplanes_ddl.sql", + ) + + apply_ods_routes_ddl = PostgresOperator( + task_id="apply_ods_routes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/routes_ddl.sql", + ) + + apply_ods_seats_ddl = PostgresOperator( + task_id="apply_ods_seats_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/seats_ddl.sql", + ) + + apply_ods_bookings_ddl = PostgresOperator( + task_id="apply_ods_bookings_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/bookings_ddl.sql", + ) + + apply_ods_tickets_ddl = PostgresOperator( + task_id="apply_ods_tickets_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/tickets_ddl.sql", + ) + + apply_ods_flights_ddl = PostgresOperator( + task_id="apply_ods_flights_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/flights_ddl.sql", + ) + + apply_ods_segments_ddl = PostgresOperator( + task_id="apply_ods_segments_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/segments_ddl.sql", + ) + + apply_ods_boarding_passes_ddl = PostgresOperator( + task_id="apply_ods_boarding_passes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/boarding_passes_ddl.sql", + ) + + # DDL применяем последовательно, чтобы порядок был понятным для новичков, + # а ошибки — воспроизводимыми (в логах сразу видно, на каком объекте упали). + ( + apply_ods_airports_ddl + >> apply_ods_airplanes_ddl + >> apply_ods_routes_ddl + >> apply_ods_seats_ddl + >> apply_ods_bookings_ddl + >> apply_ods_tickets_ddl + >> apply_ods_flights_ddl + >> apply_ods_segments_ddl + >> apply_ods_boarding_passes_ddl + ) diff --git a/airflow/dags/bookings_stg_ddl.py b/airflow/dags/bookings_stg_ddl.py index 7c17cdd..43d7bf2 100644 --- a/airflow/dags/bookings_stg_ddl.py +++ b/airflow/dags/bookings_stg_ddl.py @@ -1,12 +1,16 @@ from __future__ import annotations """ -Учебный DAG: создаёт схему stg и таблицы bookings_ext/bookings в Greenplum. -Запускается вручную перед DAG загрузки bookings_to_gp_stage или после изменения DDL. +Учебный DAG: создаёт/обновляет слой stg в Greenplum для демо-источника bookings. + +Запускается вручную перед DAG загрузки `bookings_to_gp_stage` или после изменения DDL. +Создаёт внешние таблицы PXF (`*_ext`) и внутренние таблицы STG (9 таблиц: bookings, tickets, +airports, airplanes, routes, seats, flights, segments, boarding_passes). """ -from datetime import datetime, timedelta +from datetime import timedelta +import pendulum from airflow.providers.postgres.operators.postgres import PostgresOperator from airflow import DAG @@ -17,16 +21,80 @@ default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(secon with DAG( dag_id="bookings_stg_ddl", - start_date=datetime(2024, 1, 1), + start_date=pendulum.datetime(2017, 1, 1, tz="UTC"), schedule=None, catchup=False, template_searchpath="/sql", default_args=default_args, tags=["demo", "greenplum", "ddl", "bookings", "stg"], - description="Создаёт/обновляет stg.bookings_ext и stg.bookings для учебного DAG", + description="Учебный DDL DAG: создаёт/обновляет stg.* (PXF external + internal STG) для bookings", ) as dag: apply_stg_bookings_ddl = PostgresOperator( task_id="apply_stg_bookings_ddl", postgres_conn_id=GREENPLUM_CONN_ID, sql="stg/bookings_ddl.sql", ) + + apply_stg_tickets_ddl = PostgresOperator( + task_id="apply_stg_tickets_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/tickets_ddl.sql", + ) + + # DDL для справочников + apply_stg_airports_ddl = PostgresOperator( + task_id="apply_stg_airports_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airports_ddl.sql", + ) + + apply_stg_airplanes_ddl = PostgresOperator( + task_id="apply_stg_airplanes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airplanes_ddl.sql", + ) + + apply_stg_routes_ddl = PostgresOperator( + task_id="apply_stg_routes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/routes_ddl.sql", + ) + + apply_stg_seats_ddl = PostgresOperator( + task_id="apply_stg_seats_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/seats_ddl.sql", + ) + + # DDL для транзакционных таблиц + apply_stg_flights_ddl = PostgresOperator( + task_id="apply_stg_flights_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/flights_ddl.sql", + ) + + apply_stg_segments_ddl = PostgresOperator( + task_id="apply_stg_segments_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/segments_ddl.sql", + ) + + apply_stg_boarding_passes_ddl = PostgresOperator( + task_id="apply_stg_boarding_passes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/boarding_passes_ddl.sql", + ) + + # DDL применяем последовательно, чтобы порядок был понятным для новичков, + # а ошибки — воспроизводимыми (в логах сразу видно, на каком объекте упали). + ( + apply_stg_bookings_ddl + >> apply_stg_tickets_ddl + >> apply_stg_airports_ddl + >> apply_stg_airplanes_ddl + >> apply_stg_routes_ddl + >> apply_stg_seats_ddl + >> apply_stg_flights_ddl + >> apply_stg_segments_ddl + >> apply_stg_boarding_passes_ddl + ) diff --git a/airflow/dags/bookings_to_gp_dds.py b/airflow/dags/bookings_to_gp_dds.py new file mode 100644 index 0000000..ee44efa --- /dev/null +++ b/airflow/dags/bookings_to_gp_dds.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +""" +Учебный DAG: загрузка из ODS в DDS (Greenplum) по домену bookings. + +Ключевая идея: +- DDS читает текущее состояние ODS; +- для каждой сущности выполняем пару задач load -> dq; +- для dim_routes применяем SCD2, для остальных измерений — SCD1 UPSERT; +- факт грузим инкрементальным UPSERT по зерну (ticket_no, flight_id). +""" + +from datetime import timedelta +from logging import getLogger + +import pendulum +from airflow.operators.python import PythonOperator +from airflow.providers.postgres.operators.postgres import PostgresOperator + +from airflow import DAG + +GREENPLUM_CONN_ID = "greenplum_conn" + +log = getLogger(__name__) + +default_args = { + "owner": "airflow", + "retries": 1, + "retry_delay": timedelta(seconds=30), +} + + +def _finish_summary() -> None: + """Логирует краткий итог выполнения DDS-ветки.""" + log.info("DAG bookings_to_gp_dds завершён. Подробности смотрите в логах задач.") + + +with DAG( + dag_id="bookings_to_gp_dds", + 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", "dds"], + description="Учебный DAG: загрузка ODS -> DDS (Star Schema) + DQ проверки", +) as dag: + load_dds_dim_calendar = PostgresOperator( + task_id="load_dds_dim_calendar", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_calendar_load.sql", + ) + + dq_dds_dim_calendar = PostgresOperator( + task_id="dq_dds_dim_calendar", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_calendar_dq.sql", + ) + + load_dds_dim_airports = PostgresOperator( + task_id="load_dds_dim_airports", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_airports_load.sql", + ) + + dq_dds_dim_airports = PostgresOperator( + task_id="dq_dds_dim_airports", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_airports_dq.sql", + ) + + load_dds_dim_airplanes = PostgresOperator( + task_id="load_dds_dim_airplanes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_airplanes_load.sql", + ) + + dq_dds_dim_airplanes = PostgresOperator( + task_id="dq_dds_dim_airplanes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_airplanes_dq.sql", + ) + + load_dds_dim_tariffs = PostgresOperator( + task_id="load_dds_dim_tariffs", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_tariffs_load.sql", + ) + + dq_dds_dim_tariffs = PostgresOperator( + task_id="dq_dds_dim_tariffs", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_tariffs_dq.sql", + ) + + load_dds_dim_passengers = PostgresOperator( + task_id="load_dds_dim_passengers", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_passengers_load.sql", + ) + + dq_dds_dim_passengers = PostgresOperator( + task_id="dq_dds_dim_passengers", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_passengers_dq.sql", + ) + + load_dds_dim_routes = PostgresOperator( + task_id="load_dds_dim_routes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_routes_load.sql", + ) + + dq_dds_dim_routes = PostgresOperator( + task_id="dq_dds_dim_routes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_routes_dq.sql", + ) + + load_dds_fact_flight_sales = PostgresOperator( + task_id="load_dds_fact_flight_sales", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/fact_flight_sales_load.sql", + ) + + dq_dds_fact_flight_sales = PostgresOperator( + task_id="dq_dds_fact_flight_sales", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/fact_flight_sales_dq.sql", + ) + + finish_dds_summary = PythonOperator( + task_id="finish_dds_summary", + python_callable=_finish_summary, + ) + + load_dds_dim_calendar >> dq_dds_dim_calendar + + 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 + + load_dds_dim_airports >> dq_dds_dim_airports + load_dds_dim_airplanes >> dq_dds_dim_airplanes + + load_dds_dim_tariffs >> dq_dds_dim_tariffs + load_dds_dim_passengers >> dq_dds_dim_passengers + load_dds_dim_routes >> dq_dds_dim_routes + + [ + dq_dds_dim_airports, + dq_dds_dim_airplanes, + dq_dds_dim_tariffs, + dq_dds_dim_passengers, + dq_dds_dim_routes, + ] >> load_dds_fact_flight_sales + + load_dds_fact_flight_sales >> dq_dds_fact_flight_sales >> finish_dds_summary diff --git a/airflow/dags/bookings_to_gp_dm.py b/airflow/dags/bookings_to_gp_dm.py new file mode 100644 index 0000000..c2cfb57 --- /dev/null +++ b/airflow/dags/bookings_to_gp_dm.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +""" +Учебный DAG: загрузка из DDS в DM (Greenplum) по домену bookings. + +Ключевая идея: +- DM читает из DDS (Star Schema); +- для каждой витрины выполняем пару задач load -> dq; +- все витрины загружаются параллельно (не зависят друг от друга); +- sales_report использует UPSERT (heap-таблица), route_performance — Full Rebuild (AO Column). + +На данном этапе реализованы все 5 витрин: sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview. +""" + +from datetime import timedelta +from logging import getLogger + +import pendulum +from airflow.operators.python import PythonOperator +from airflow.providers.postgres.operators.postgres import PostgresOperator + +from airflow import DAG + +GREENPLUM_CONN_ID = "greenplum_conn" + +log = getLogger(__name__) + +default_args = { + "owner": "airflow", + "retries": 1, + "retry_delay": timedelta(seconds=30), +} + + +def _finish_summary() -> None: + """Логирует краткий итог выполнения DM-ветки.""" + log.info("DAG bookings_to_gp_dm завершён. Подробности смотрите в логах задач.") + + +with DAG( + dag_id="bookings_to_gp_dm", + 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", "dm"], + description="Учебный DAG: загрузка DDS -> DM (Data Mart) + DQ проверки", +) as dag: + # start-задача (для структуры, вдруг понадобятся pre-checks) + start_dm = PythonOperator( + task_id="start_dm", + python_callable=lambda: log.info("Начало загрузки DM-слоя..."), + ) + + # === Эталонная витрина: sales_report === + load_dm_sales_report = PostgresOperator( + task_id="load_dm_sales_report", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/sales_report_load.sql", + ) + + dq_dm_sales_report = PostgresOperator( + task_id="dq_dm_sales_report", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/sales_report_dq.sql", + ) + + # === Витрина: Эффективность маршрутов (Этап 2) === + load_dm_route_performance = PostgresOperator( + task_id="load_dm_route_performance", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/route_performance_load.sql", + ) + + dq_dm_route_performance = PostgresOperator( + task_id="dq_dm_route_performance", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/route_performance_dq.sql", + ) + + # === Витрина: Лояльность пассажиров (Этап 3) === + load_dm_passenger_loyalty = PostgresOperator( + task_id="load_dm_passenger_loyalty", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/passenger_loyalty_load.sql", + ) + + dq_dm_passenger_loyalty = PostgresOperator( + task_id="dq_dm_passenger_loyalty", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/passenger_loyalty_dq.sql", + ) + + # === Витрина: Пассажиропоток аэропортов (Этап 4) === + load_dm_airport_traffic = PostgresOperator( + task_id="load_dm_airport_traffic", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/airport_traffic_load.sql", + ) + + dq_dm_airport_traffic = PostgresOperator( + task_id="dq_dm_airport_traffic", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/airport_traffic_dq.sql", + ) + + # === Витрина: Помесячная сводка (Этап 5) === + load_dm_monthly_overview = PostgresOperator( + task_id="load_dm_monthly_overview", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/monthly_overview_load.sql", + ) + + dq_dm_monthly_overview = PostgresOperator( + task_id="dq_dm_monthly_overview", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dm/monthly_overview_dq.sql", + ) + + # finish-задача + finish_dm_summary = PythonOperator( + task_id="finish_dm_summary", + python_callable=_finish_summary, + ) + + # Зависимости: параллельные ветки load -> dq + start_dm >> [ + load_dm_sales_report, + load_dm_route_performance, + load_dm_passenger_loyalty, + load_dm_airport_traffic, + load_dm_monthly_overview, + ] + + # Связываем dq с finish + load_dm_sales_report >> dq_dm_sales_report >> finish_dm_summary + load_dm_route_performance >> dq_dm_route_performance >> finish_dm_summary + load_dm_passenger_loyalty >> dq_dm_passenger_loyalty >> finish_dm_summary + load_dm_airport_traffic >> dq_dm_airport_traffic >> finish_dm_summary + load_dm_monthly_overview >> dq_dm_monthly_overview >> finish_dm_summary diff --git a/airflow/dags/bookings_to_gp_ods.py b/airflow/dags/bookings_to_gp_ods.py new file mode 100644 index 0000000..a38287d --- /dev/null +++ b/airflow/dags/bookings_to_gp_ods.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +""" +Учебный DAG: загрузка из STG в ODS (Greenplum) по домену bookings. + +Ключевая идея: +- весь запуск ODS работает с одним stg_batch_id; +- для каждой сущности выполняем пару задач load -> dq; +- загрузка реализована как SCD1 UPSERT (UPDATE изменившихся + INSERT новых). +""" + +from datetime import timedelta +from logging import getLogger + +import pendulum +from airflow.operators.python import PythonOperator +from airflow.providers.postgres.hooks.postgres import PostgresHook +from airflow.providers.postgres.operators.postgres import PostgresOperator + +from airflow import DAG + +GREENPLUM_CONN_ID = "greenplum_conn" + +log = getLogger(__name__) + +default_args = { + "owner": "airflow", + "retries": 1, + "retry_delay": timedelta(seconds=30), +} + + +def _resolve_stg_batch_id(**context) -> str: + """ + Возвращает stg_batch_id из dag_run.conf или вычисляет последний согласованный батч. + + ВНИМАНИЕ: Это значение используется ТОЛЬКО для загрузки snapshot-справочников + (airports, airplanes, routes, seats). + + Для инкрементальных транзакционных таблиц (bookings, tickets, flights, segments, + boarding_passes) этот батч НЕ используется. Вместо этого они грузят все новые + записи по HWM: WHERE _load_ts > (SELECT MAX(_load_ts) FROM ods.table). + Это сделано для того, чтобы не потерять инкременты, если STG-DAG запускался + несколько раз до запуска ODS-DAG'а. + + Зачем нужна согласованность (INTERSECT по всем справочникам)? + Чтобы ODS загружал только те данные, для которых уже приехали ВСЕ связанные + справочники. Это защищает от рассинхрона данных, когда часть измерений в текущем + батче обновилась, а часть — упала или не доехала, что могло бы привести к + потере ссылочной целостности при сборке витрин. + """ + conf = context["dag_run"].conf or {} + stg_batch_id = conf.get("stg_batch_id") + + if not stg_batch_id: + hook = PostgresHook(postgres_conn_id=GREENPLUM_CONN_ID) + result = hook.get_first( + """ + WITH candidate_batches AS ( + SELECT _load_id + FROM stg.airports + WHERE _load_id IS NOT NULL AND _load_id <> '' + GROUP BY _load_id + INTERSECT + SELECT _load_id + FROM stg.airplanes + WHERE _load_id IS NOT NULL AND _load_id <> '' + GROUP BY _load_id + INTERSECT + SELECT _load_id + FROM stg.routes + WHERE _load_id IS NOT NULL AND _load_id <> '' + GROUP BY _load_id + INTERSECT + SELECT _load_id + FROM stg.seats + WHERE _load_id IS NOT NULL AND _load_id <> '' + GROUP BY _load_id + ), + batch_ready AS ( + SELECT + c._load_id, + GREATEST( + COALESCE( + (SELECT MAX(_load_ts) FROM stg.airports a WHERE a._load_id = c._load_id), + TIMESTAMP '1900-01-01 00:00:00' + ), + COALESCE( + (SELECT MAX(_load_ts) FROM stg.airplanes a WHERE a._load_id = c._load_id), + TIMESTAMP '1900-01-01 00:00:00' + ), + COALESCE( + (SELECT MAX(_load_ts) FROM stg.routes r WHERE r._load_id = c._load_id), + TIMESTAMP '1900-01-01 00:00:00' + ), + COALESCE( + (SELECT MAX(_load_ts) FROM stg.seats s WHERE s._load_id = c._load_id), + TIMESTAMP '1900-01-01 00:00:00' + ) + ) AS ready_dttm + FROM candidate_batches c + ) + SELECT _load_id + FROM batch_ready + ORDER BY ready_dttm DESC + LIMIT 1 + """ + ) + stg_batch_id = result[0] if result and result[0] else None + + if not stg_batch_id: + raise ValueError( + "stg_batch_id не найден: передайте stg_batch_id в conf или " + "сначала выполните bookings_to_gp_stage для snapshot-справочников" + ) + + log.info("Используем stg_batch_id=%s", stg_batch_id) + return stg_batch_id + + +def _finish_summary() -> None: + """Логирует краткий итог выполнения ODS-ветки.""" + log.info("DAG bookings_to_gp_ods завершён. Подробности смотрите в логах задач.") + + +with DAG( + dag_id="bookings_to_gp_ods", + 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", "ods"], + description="Учебный DAG: загрузка STG -> ODS (SCD1 UPSERT) + DQ проверки", +) as dag: + resolve_stg_batch_id = PythonOperator( + task_id="resolve_stg_batch_id", + python_callable=_resolve_stg_batch_id, + ) + + load_ods_bookings = PostgresOperator( + task_id="load_ods_bookings", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/bookings_load.sql", + ) + + dq_ods_bookings = PostgresOperator( + task_id="dq_ods_bookings", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/bookings_dq.sql", + ) + + load_ods_tickets = PostgresOperator( + task_id="load_ods_tickets", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/tickets_load.sql", + ) + + dq_ods_tickets = PostgresOperator( + task_id="dq_ods_tickets", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/tickets_dq.sql", + ) + + load_ods_airports = PostgresOperator( + task_id="load_ods_airports", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/airports_load.sql", + ) + + dq_ods_airports = PostgresOperator( + task_id="dq_ods_airports", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/airports_dq.sql", + ) + + load_ods_airplanes = PostgresOperator( + task_id="load_ods_airplanes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/airplanes_load.sql", + ) + + dq_ods_airplanes = PostgresOperator( + task_id="dq_ods_airplanes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/airplanes_dq.sql", + ) + + load_ods_routes = PostgresOperator( + task_id="load_ods_routes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/routes_load.sql", + ) + + dq_ods_routes = PostgresOperator( + task_id="dq_ods_routes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/routes_dq.sql", + ) + + load_ods_seats = PostgresOperator( + task_id="load_ods_seats", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/seats_load.sql", + ) + + dq_ods_seats = PostgresOperator( + task_id="dq_ods_seats", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/seats_dq.sql", + ) + + load_ods_flights = PostgresOperator( + task_id="load_ods_flights", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/flights_load.sql", + ) + + dq_ods_flights = PostgresOperator( + task_id="dq_ods_flights", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/flights_dq.sql", + ) + + load_ods_segments = PostgresOperator( + task_id="load_ods_segments", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/segments_load.sql", + ) + + dq_ods_segments = PostgresOperator( + task_id="dq_ods_segments", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/segments_dq.sql", + ) + + load_ods_boarding_passes = PostgresOperator( + task_id="load_ods_boarding_passes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/boarding_passes_load.sql", + ) + + dq_ods_boarding_passes = PostgresOperator( + task_id="dq_ods_boarding_passes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/boarding_passes_dq.sql", + ) + + finish_ods_summary = PythonOperator( + task_id="finish_ods_summary", + python_callable=_finish_summary, + ) + + # stg_batch_id нужен всем загрузочным веткам. + ( + resolve_stg_batch_id + >> load_ods_bookings + >> dq_ods_bookings + >> load_ods_tickets + >> dq_ods_tickets + ) + 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 + dq_ods_airplanes >> load_ods_seats >> dq_ods_seats + + dq_ods_routes >> load_ods_flights >> dq_ods_flights + [dq_ods_flights, dq_ods_tickets] >> load_ods_segments >> dq_ods_segments + dq_ods_segments >> load_ods_boarding_passes >> dq_ods_boarding_passes + + [dq_ods_boarding_passes, dq_ods_seats] >> finish_ods_summary diff --git a/airflow/dags/bookings_to_gp_stage.py b/airflow/dags/bookings_to_gp_stage.py index 1112106..6097b9d 100644 --- a/airflow/dags/bookings_to_gp_stage.py +++ b/airflow/dags/bookings_to_gp_stage.py @@ -12,11 +12,31 @@ from __future__ import annotations Каждый запуск DAG работает как «шаг по времени вперёд»: - генератор в демо-БД bookings добавляет следующий учебный день после max(book_date); - загрузка в Greenplum берёт все строки, появившиеся после предыдущих батчей; -- `run_id` используется как метка запуска (в `batch_id`, в логах и DQ). +- `run_id` используется как метка запуска (в `_load_id`, в логах и DQ). + +Важно: для инкрементальных таблиц «пустое окно инкремента» допустимо (это не ошибка). + +Граф зависимостей (параллельный там, где данные независимы): + + generate_bookings_day → load_bookings → check_bookings_dq + → load_tickets → check_tickets_dq + ├─ load_airports → check_airports_dq ─┐ + │ ├─ load_routes → check_routes_dq + ├─ load_airplanes → check_airplanes_dq ┤ → load_flights → check_flights_dq + │ │ → load_segments → check_segments_dq + │ │ → load_boarding_passes → check_bp_dq ─┐ + │ └─ load_seats → check_seats_dq ──────────────────────┤ + │ ▼ + └──────────────────────────────────────────────────────────────────────────── finish_summary + +airports и airplanes грузятся параллельно (они не зависят друг от друга). +routes зависит от обоих (DQ проверяет ссылочную целостность на airports и airplanes). +seats зависит только от airplanes (DQ проверяет airplane_code → airplanes). """ -from datetime import datetime, timedelta +from datetime import timedelta +import pendulum from airflow.operators.python import PythonOperator from airflow.providers.postgres.operators.postgres import PostgresOperator @@ -48,13 +68,14 @@ def _finish_summary() -> None: with DAG( dag_id="bookings_to_gp_stage", - start_date=datetime(2024, 1, 1), + 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", "stg"], - description="Учебный DAG: загрузка из bookings-db в stg.bookings (Greenplum)", + description="Учебный DAG: загрузка из bookings-db в слой stg (Greenplum) + DQ проверки", ) as dag: # 1. Генерируем один (или несколько стартовых) учебный день в демо-БД bookings generate_bookings_day = PostgresOperator( @@ -78,10 +99,136 @@ with DAG( sql="stg/bookings_dq.sql", ) - # 4. Финальный лог/сводка + # 4. Загружаем инкремент билетов из stg.tickets_ext в stg.tickets + load_tickets_to_stg = PostgresOperator( + task_id="load_tickets_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/tickets_load.sql", + ) + + # 5. Проверяем качество данных для tickets + check_tickets_dq = PostgresOperator( + task_id="check_tickets_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/tickets_dq.sql", + ) + + # Загрузка справочников (full load) + load_airports_to_stg = PostgresOperator( + task_id="load_airports_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airports_load.sql", + ) + + check_airports_dq = PostgresOperator( + task_id="check_airports_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airports_dq.sql", + ) + + load_airplanes_to_stg = PostgresOperator( + task_id="load_airplanes_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airplanes_load.sql", + ) + + check_airplanes_dq = PostgresOperator( + task_id="check_airplanes_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airplanes_dq.sql", + ) + + load_routes_to_stg = PostgresOperator( + task_id="load_routes_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/routes_load.sql", + ) + + check_routes_dq = PostgresOperator( + task_id="check_routes_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/routes_dq.sql", + ) + + load_seats_to_stg = PostgresOperator( + task_id="load_seats_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/seats_load.sql", + ) + + check_seats_dq = PostgresOperator( + task_id="check_seats_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/seats_dq.sql", + ) + + # Загрузка транзакций (инкремент) + load_flights_to_stg = PostgresOperator( + task_id="load_flights_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/flights_load.sql", + ) + + check_flights_dq = PostgresOperator( + task_id="check_flights_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/flights_dq.sql", + ) + + load_segments_to_stg = PostgresOperator( + task_id="load_segments_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/segments_load.sql", + ) + + check_segments_dq = PostgresOperator( + task_id="check_segments_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/segments_dq.sql", + ) + + load_boarding_passes_to_stg = PostgresOperator( + task_id="load_boarding_passes_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/boarding_passes_load.sql", + ) + + check_boarding_passes_dq = PostgresOperator( + task_id="check_boarding_passes_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/boarding_passes_dq.sql", + ) + + # Финальный лог/сводка finish_summary = PythonOperator( task_id="finish_summary", python_callable=_finish_summary, ) - generate_bookings_day >> load_bookings_to_stg >> check_row_counts >> finish_summary + # === Этап 1. Транзакции: bookings → tickets (последовательно, т.к. tickets зависят от bookings) === + generate_bookings_day >> load_bookings_to_stg >> check_row_counts + check_row_counts >> load_tickets_to_stg >> check_tickets_dq + + # === Этап 2. Справочники (параллельно, где данные независимы) === + # airports и airplanes не зависят друг от друга — грузим параллельно. + check_tickets_dq >> load_airports_to_stg >> check_airports_dq + check_tickets_dq >> load_airplanes_to_stg >> check_airplanes_dq + + # routes зависит от airports И airplanes (DQ проверяет ссылочную целостность). + [check_airports_dq, check_airplanes_dq] >> load_routes_to_stg >> check_routes_dq + + # seats зависит только от airplanes (DQ проверяет ссылочную целостность). + check_airplanes_dq >> load_seats_to_stg >> check_seats_dq + + # === Этап 3. Транзакции (последовательно, каждая зависит от предыдущей) === + # flights зависят от routes (DQ проверяет ссылочную целостность route_no → routes). + check_routes_dq >> load_flights_to_stg >> check_flights_dq + + # segments зависят от flights и tickets (DQ проверяет обе ссылки). + check_flights_dq >> load_segments_to_stg >> check_segments_dq + + # boarding_passes зависят от segments и tickets (DQ проверяет обе ссылки). + check_segments_dq >> load_boarding_passes_to_stg >> check_boarding_passes_dq + + # === Финал: ждём завершения ВСЕХ веток === + [check_boarding_passes_dq, check_seats_dq] >> finish_summary diff --git a/airflow/dags/bookings_validate.py b/airflow/dags/bookings_validate.py new file mode 100644 index 0000000..7bca8d0 --- /dev/null +++ b/airflow/dags/bookings_validate.py @@ -0,0 +1,150 @@ +""" +Валидационный DAG: самопроверка студенческих заданий. + +Запускается вручную в Airflow UI после реализации заданий. +Таски сгруппированы по слоям — студент видит, где именно проблема. + +Структура: + validate_ods — проверки ODS: rowcount, дубли BK, NULL в PK + validate_dds — проверки DDS: SCD1-измерения, SCD2 dim_routes (активный тест!) + validate_dm — проверки DM-витрин: не пусты, нет NULL в ключах + +Все три группы запускаются параллельно — инкрементальная обратная связь: +студент может проверить только ODS, пока DDS/DM ещё не реализованы. +""" + +from datetime import timedelta +from logging import getLogger + +import pendulum +from airflow.providers.postgres.operators.postgres import PostgresOperator +from airflow.utils.task_group import TaskGroup + +from airflow import DAG + +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", + ) diff --git a/airflow/dags/csv_to_greenplum.py b/airflow/dags/csv_to_greenplum.py deleted file mode 100644 index 9289403..0000000 --- a/airflow/dags/csv_to_greenplum.py +++ /dev/null @@ -1,167 +0,0 @@ -from __future__ import annotations - -import logging -import os -import random -from datetime import datetime, timedelta -from pathlib import Path -from typing import List - -import pandas as pd -from airflow.operators.python import PythonOperator -from helpers.greenplum import get_gp_conn - -from airflow import DAG - -CSV_DIR = Path(os.getenv("CSV_DIR", "/opt/airflow/data")) -CSV_ROWS = int(os.getenv("CSV_ROWS", "1000")) - - -def _create_table() -> None: - """Создаёт таблицу public.orders, если она ещё не существует.""" - ddl = """ - CREATE TABLE IF NOT EXISTS public.orders ( - order_id BIGINT, - order_ts TIMESTAMP NOT NULL, - customer_id BIGINT NOT NULL, - amount NUMERIC(12,2) NOT NULL - ) - WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) - DISTRIBUTED BY (order_id); - """ - with get_gp_conn() as conn, conn.cursor() as cur: - cur.execute(ddl) - conn.commit() - - -def _generate_csv(rows: int, csv_dir: Path) -> str: - """Генерирует CSV c заказами с помощью pandas и сохраняет на диск.""" - csv_dir.mkdir(parents=True, exist_ok=True) - timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") - csv_path = csv_dir / f"orders_{timestamp}.csv" - - # Генерируем данные в pandas-стиле - base_order_id = int(datetime.utcnow().timestamp() * 1_000) - - # Создаём DataFrame с использованием pandas методов - df = pd.DataFrame( - { - # Уникальные order_id начиная с базового значения - "order_id": pd.Series( - range(base_order_id, base_order_id + rows), dtype="int64" - ), - # Временные метки с интервалом в 1 секунду в обратном порядке - "order_ts": pd.date_range( - end=datetime.utcnow(), periods=rows, freq="1S" - ).sort_values(ascending=False), - # Случайные customer_id от 1 до 1000 - "customer_id": pd.Series( - random.choices(range(1, 1001), k=rows), dtype="int64" - ), - # Случайные суммы от 10 до 500 с округлением до 2 знаков - "amount": pd.Series( - [round(random.uniform(10, 500), 2) for _ in range(rows)], - dtype="float64", - ), - } - ) - - # Сохраняем CSV без индекса - df.to_csv(csv_path, index=False) - logging.info("CSV сохранён: %s (строк: %s)", csv_path, len(df)) - return str(csv_path) - - -def _preview_csv(csv_path: str, sample_rows: int = 5) -> None: - """Отображает предпросмотр CSV через pandas (head и describe).""" - df = pd.read_csv(csv_path) - df["order_ts"] = pd.to_datetime(df["order_ts"], errors="coerce") - logging.info( - "Первые %s строк:\n%s", sample_rows, df.head(sample_rows).to_string(index=False) - ) - numeric_summary = df.describe(include="number") - logging.info("Числовая статистика:\n%s", numeric_summary.to_string()) - if df["order_ts"].notna().any(): - logging.info( - "Диапазон order_ts: %s → %s", - df["order_ts"].min().isoformat(), - df["order_ts"].max().isoformat(), - ) - - -def _load_csv(csv_path: str) -> None: - """Загружает CSV в Greenplum через временную таблицу и anti-join.""" - csv_file = Path(csv_path) - if not csv_file.exists(): - raise FileNotFoundError(f"CSV не найден: {csv_file}") - - with ( - get_gp_conn() as conn, - conn.cursor() as cur, - csv_file.open("r", encoding="utf-8") as f, - ): - cur.execute( - "CREATE TEMP TABLE tmp_orders (LIKE public.orders INCLUDING DEFAULTS) ON COMMIT DROP;" - ) - cur.copy_expert( - "COPY tmp_orders (order_id, order_ts, customer_id, amount) FROM STDIN WITH CSV HEADER", - f, - ) - - cur.execute("SELECT COUNT(*) FROM tmp_orders") - tmp_rows = cur.fetchone()[0] - - cur.execute( - """ - INSERT INTO public.orders(order_id, order_ts, customer_id, amount) - SELECT t.order_id, t.order_ts, t.customer_id, t.amount - FROM tmp_orders t - LEFT JOIN public.orders o ON o.order_id = t.order_id - WHERE o.order_id IS NULL - """ - ) - inserted = cur.rowcount if cur.rowcount != -1 else 0 - conn.commit() - - logging.info("Загружено строк: %s (прочитано из CSV: %s)", inserted, tmp_rows) - - -default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)} - -with DAG( - dag_id="csv_to_greenplum", - start_date=datetime(2024, 1, 1), - schedule=None, - catchup=False, - default_args=default_args, - tags=["demo", "greenplum", "csv"], -) as dag: - create_table = PythonOperator( - task_id="create_orders_table", - python_callable=_create_table, - ) - - generate_csv = PythonOperator( - task_id="generate_csv", - python_callable=_generate_csv, - op_kwargs={"rows": CSV_ROWS, "csv_dir": CSV_DIR}, - ) - - preview_csv = PythonOperator( - task_id="preview_csv", - python_callable=_preview_csv, - op_kwargs={ - "csv_path": "{{ ti.xcom_pull(task_ids='generate_csv') }}", - "sample_rows": 5, - }, - ) - - load_csv = PythonOperator( - task_id="load_csv_to_greenplum", - python_callable=_load_csv, - op_kwargs={ - "csv_path": "{{ ti.xcom_pull(task_ids='generate_csv') }}", - }, - ) - - create_table >> generate_csv >> preview_csv >> load_csv diff --git a/airflow/dags/csv_to_greenplum_dq.py b/airflow/dags/csv_to_greenplum_dq.py deleted file mode 100644 index 02ae600..0000000 --- a/airflow/dags/csv_to_greenplum_dq.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -import logging -from datetime import datetime, timedelta - -from airflow.operators.python import PythonOperator -from helpers.greenplum import ( - assert_orders_have_rows, - assert_orders_no_duplicates, - assert_orders_schema, - assert_orders_table_exists, - get_gp_conn, -) - -from airflow import DAG - - -def _run_check(check_callable): - """ - Оборачивает проверку качества данных в контекст подключения к Greenplum. - - Этот DAG предназначен для автоматической проверки качества данных - после CSV-пайплайна в таблице public.orders: - 1. Проверяет существование таблицы - 2. Проверяет соответствие схемы - 3. Проверяет наличие данных - 4. Проверяет отсутствие дубликатов - - Args: - check_callable: Функция проверки, принимающая подключение к БД - """ - # Получаем имя функции для логов - check_name = check_callable.__name__.replace("assert_", "") - logging.info("🚀 Запуск проверки: %s", check_name) - - with get_gp_conn() as conn: - check_callable(conn) - - logging.info("✅ Проверка пройдена: %s", check_name) - - -def _log_dq_summary(): - """ - Логирует итоговую сводку по качеству данных. - Эта задача выполняется после всех проверок и показывает общий результат. - """ - logging.info("🎉 Все проверки качества данных пройдены успешно!") - logging.info("📊 Качество данных в таблице orders соответствует требованиям.") - - -default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)} - -with DAG( - dag_id="csv_to_greenplum_dq", - start_date=datetime(2024, 1, 1), - schedule=None, - catchup=False, - default_args=default_args, - tags=["demo", "greenplum", "quality", "csv", "dq"], - description="Проверки качества данных после CSV → public.orders в Greenplum", -) as dag: - # Задача 1: Проверка существования таблицы - check_exists = PythonOperator( - task_id="check_orders_table_exists", - python_callable=_run_check, - op_args=[assert_orders_table_exists], - ) - - # Задача 2: Проверка соответствия схемы таблицы - check_schema = PythonOperator( - task_id="check_orders_schema", - python_callable=_run_check, - op_args=[assert_orders_schema], - ) - - # Задача 3: Проверка наличия данных - check_has_rows = PythonOperator( - task_id="check_orders_has_rows", - python_callable=_run_check, - op_args=[assert_orders_have_rows], - ) - - # Задача 4: Проверка отсутствия дубликатов - check_no_duplicates = PythonOperator( - task_id="check_order_duplicates", - python_callable=_run_check, - op_args=[assert_orders_no_duplicates], - ) - - # Задача 5: Итоговая сводка - dq_summary = PythonOperator( - task_id="data_quality_summary", - python_callable=_log_dq_summary, - ) - - # Определяем последовательность выполнения задач - check_exists >> check_schema >> check_has_rows >> check_no_duplicates >> dq_summary diff --git a/airflow/dags/ddl_greenplum_base.py b/airflow/dags/ddl_greenplum_base.py deleted file mode 100644 index 6afd76c..0000000 --- a/airflow/dags/ddl_greenplum_base.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -""" -Учебный DAG: применяет DDL для базовой таблицы orders в Greenplum. -Запускается вручную перед CSV‑пайплайном или после изменения схемы. -""" - -from datetime import datetime, timedelta - -from airflow.providers.postgres.operators.postgres import PostgresOperator - -from airflow import DAG - -GREENPLUM_CONN_ID = "greenplum_conn" - -default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)} - -with DAG( - dag_id="orders_base_ddl", - start_date=datetime(2024, 1, 1), - schedule=None, - catchup=False, - template_searchpath="/sql", - default_args=default_args, - tags=["demo", "greenplum", "ddl", "orders"], - description="Создаёт/обновляет базовую таблицу orders в схеме public", -) as dag: - apply_orders_ddl = PostgresOperator( - task_id="apply_orders_ddl", - postgres_conn_id=GREENPLUM_CONN_ID, - sql="base/orders_ddl.sql", - ) diff --git a/airflow/dags/helpers/greenplum.py b/airflow/dags/helpers/greenplum.py deleted file mode 100644 index 65f86d0..0000000 --- a/airflow/dags/helpers/greenplum.py +++ /dev/null @@ -1,216 +0,0 @@ -from __future__ import annotations - -import logging -import os -from typing import List, Sequence, Tuple - -import psycopg2 - -# Настройки для подключения к Greenplum. По умолчанию используем Airflow Connection, -# но при проблемах можно переключиться на ENV-подключение, установив GP_USE_AIRFLOW_CONN=false. -GP_CONN_ID = os.getenv("GP_CONN_ID", "greenplum_conn") -GP_USE_AIRFLOW_CONN = os.getenv("GP_USE_AIRFLOW_CONN", "true").lower() in ( - "1", - "true", - "yes", -) - -# Ожидаемая схема таблицы orders для проверки качества данных -EXPECTED_ORDERS_SCHEMA: List[Tuple[str, str]] = [ - ("order_id", "bigint"), - ("order_ts", "timestamp without time zone"), - ("customer_id", "bigint"), - ("amount", "numeric"), -] - - -def get_gp_conn(): - """ - Возвращает psycopg2 connection к Greenplum. - - Приоритет подключения: - 1. Через Airflow Connection (если настроено и доступно) - 2. Прямое подключение по переменным окружения (фоллбек) - - Returns: - psycopg2 connection object - """ - if GP_USE_AIRFLOW_CONN: - try: - from airflow.providers.postgres.hooks.postgres import PostgresHook - - hook = PostgresHook(postgres_conn_id=GP_CONN_ID) - conn = hook.get_conn() - logging.info("✅ Подключение через Airflow Connection успешно") - return conn - except Exception as e: - logging.warning("⚠️ Не удалось подключиться через Airflow Connection: %s", e) - logging.info("🔄 Переключаемся на прямое подключение по ENV переменным") - # Фоллбек на прямое подключение по переменным окружения. - - # Прямое подключение по переменным окружения - conn_params = { - "dbname": os.getenv("GP_DB", "gp_dwh"), - "user": os.getenv("GP_USER", "gpadmin"), - "password": os.getenv("GP_PASSWORD", ""), - "host": os.getenv("GP_HOST", "greenplum"), - "port": int(os.getenv("GP_PORT", "5432")), - } - logging.info( - "🔗 Подключение к Greenplum: %s:%s/%s", - conn_params["host"], - conn_params["port"], - conn_params["dbname"], - ) - return psycopg2.connect(**conn_params) - - -def assert_orders_table_exists(conn) -> None: - """ - Проверяет наличие таблицы orders в схеме public. - - Args: - conn: Подключение к Greenplum - - Raises: - ValueError: Если таблица не найдена - """ - logging.info("🔍 Проверяем существование таблицы public.orders...") - with conn.cursor() as cur: - cur.execute( - """ - SELECT 1 - FROM pg_catalog.pg_tables - WHERE schemaname = 'public' AND tablename = 'orders' - """ - ) - if cur.fetchone() is None: - raise ValueError( - "❌ Таблица public.orders не найдена; запусти DAG csv_to_greenplum." - ) - logging.info("✅ Таблица public.orders существует") - - -def fetch_orders_schema(conn) -> Sequence[Tuple[str, str]]: - """ - Получает схему таблицы orders из information_schema. - - Args: - conn: Подключение к Greenplum - - Returns: - Список кортежей (имя_колонки, тип_данных) - """ - with conn.cursor() as cur: - cur.execute( - """ - SELECT column_name, data_type - FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'orders' - ORDER BY ordinal_position - """ - ) - return cur.fetchall() - - -def assert_orders_schema(conn) -> None: - """ - Проверяет, что схема таблицы orders соответствует ожидаемой. - - Args: - conn: Подключение к Greenplum - - Raises: - ValueError: Если схема не соответствует ожидаемой - """ - logging.info("📋 Проверяем схему таблицы orders...") - schema = fetch_orders_schema(conn) - logging.info("📊 Фактическая схема: %s", list(schema)) - logging.info("📊 Ожидаемая схема: %s", EXPECTED_ORDERS_SCHEMA) - - if list(schema) != EXPECTED_ORDERS_SCHEMA: - raise ValueError( - f"❌ Неожиданная схема orders: {schema}. Ожидали {EXPECTED_ORDERS_SCHEMA}." - ) - logging.info("✅ Схема таблицы orders соответствует ожиданиям") - - -def fetch_orders_count(conn) -> int: - """ - Получает количество строк в таблице orders. - - Args: - conn: Подключение к Greenplum - - Returns: - Количество строк в таблице - """ - with conn.cursor() as cur: - cur.execute("SELECT COUNT(*) FROM public.orders") - return cur.fetchone()[0] - - -def assert_orders_have_rows(conn) -> None: - """ - Проверяет, что таблица orders не пустая. - - Args: - conn: Подключение к Greenplum - - Raises: - ValueError: Если таблица пустая - """ - logging.info("📊 Проверяем наличие данных в таблице orders...") - row_count = fetch_orders_count(conn) - logging.info("📈 Количество строк в orders: %s", row_count) - - if row_count <= 0: - raise ValueError( - "❌ Таблица public.orders пустая — запусти DAG csv_to_greenplum перед проверкой." - ) - logging.info("✅ Таблица orders содержит данные (%s строк)", row_count) - - -def fetch_orders_duplicates(conn) -> int: - """ - Подсчитывает количество дубликатов по order_id. - - Args: - conn: Подключение к Greenplum - - Returns: - Количество дублирующихся order_id - """ - with conn.cursor() as cur: - cur.execute( - """ - SELECT COUNT(*) FROM ( - SELECT order_id - FROM public.orders - GROUP BY order_id - HAVING COUNT(*) > 1 - ) d - """ - ) - return cur.fetchone()[0] - - -def assert_orders_no_duplicates(conn) -> None: - """ - Проверяет, что в таблице нет дублей по order_id. - - Args: - conn: Подключение к Greenplum - - Raises: - ValueError: Если обнаружены дубликаты - """ - logging.info("🔍 Проверяем отсутствие дубликатов по order_id...") - duplicates = fetch_orders_duplicates(conn) - logging.info("📊 Найдено дубликатов: %s", duplicates) - - if duplicates: - raise ValueError( - f"❌ Обнаружены дубли по order_id ({duplicates} шт.) — проверь загрузку данных." - ) - logging.info("✅ Дубликаты не обнаружены") diff --git a/bookings/README.md b/bookings/README.md index 30dac82..d45e4e2 100644 --- a/bookings/README.md +++ b/bookings/README.md @@ -4,29 +4,38 @@ На первом этапе мы: - поднимаем отдельный контейнер `bookings-db` с Postgres; -- устанавливаем в нём генератор демобазы `demodb` (репозиторий `postgrespro/demodb`); +- инициализируем демобазу одним из двух способов (см. ниже); - генерируем данные «день за днём» с помощью `make`‑команд. -Основные команды см. в корневом `Makefile` (`bookings-init`, `bookings-generate-day`, `bookings-psql`) и в `README.md` проекта. +### Два способа инициализации + +| Команда | Что делает | Время | Для кого | +|---------|-----------|-------|----------| +| `make bookings-init` | Быстрое восстановление из seed-дампа | ~18 сек | **Студенты** (рекомендуется по умолчанию) | +| `make bookings-generate` | Полная генерация с нуля через генератор demodb | часы | Разработчики, пересоздание дампа | + +Основные команды см. в корневом `Makefile` (`bookings-init`, `bookings-generate`, `bookings-generate-day`, `bookings-psql`) и в `README.md` проекта. ## Источник и версия - Репозиторий демобазы: `postgrespro/demodb`. -- Закреплённый коммит: `d68de192850237719f09b47688d5f3fc94653ca6` (см. `DEMODB_COMMIT` в корневом `Makefile`). +- Закреплённый коммит: `866e56f7` (см. `DEMODB_COMMIT` в корневом `Makefile`). ## Что мы патчим в demodb - `install.sql`: `DROP DATABASE IF EXISTS demo WITH (FORCE)` — установка не падает, даже если демобазу держат активные сессии (например, из Airflow). - `engine.sql`: два изменения в `engine_jobs1_sync.patch`: - `busy()` игнорирует свой `pid`, чтобы не считать собственное подключение занятым; - `continue()` при `jobs=1` вызывает `process_queue` синхронно (без `dblink`), иначе генерация обрывается при выходе из `psql` и данных не появляется. -- Режим эксплуатации в этом стенде: только `jobs=1` (`BOOKINGS_JOBS=1`). -- Патчи применяются автоматически в `make bookings-init`. Если что-то пошло не так, их можно накатить вручную: +- `install.sql`: удалён хардкод `gen.connstr` без credentials (`install_connstr_no_hardcode.patch`). +- Дефолт: `BOOKINGS_JOBS=1` (синхронно, без dblink — оптимально для +1 дня, ~3 мин). При `jobs>1` — через dblink, но на WSL2 в 3× медленнее из-за lock contention на `gen.events`. +- Патчи применяются автоматически в `make bookings-generate` (генерация с нуля). Если что-то пошло не так, их можно накатить вручную: ``` patch -d bookings/demodb -p1 --forward < bookings/patches/install_drop_if_exists.patch patch -d bookings/demodb -p1 --forward < bookings/patches/engine_jobs1_sync.patch ``` ## Быстрая проверка после init/обновления -- `make bookings-init` должен завершиться без ошибок; в `bookings.bookings` ожидаем >0 строк (примерно 15k). +- `make bookings-init` (восстановление из дампа) должен завершиться без ошибок; в `bookings.bookings` ожидаем >0 строк (примерно 15k). +- `make bookings-generate` (генерация с нуля) тоже должен дать >0 строк, но занимает значительно больше времени. - `make bookings-generate-day` добавляет следующий день после `max(book_date)`. - Ручной вызов генерации из psql/DBeaver — только через DO-блок (подзапрос в аргументах `CALL` не работает): ```sql diff --git a/bookings/generate_next_day.sql b/bookings/generate_next_day.sql index 236c986..a3ee380 100644 --- a/bookings/generate_next_day.sql +++ b/bookings/generate_next_day.sql @@ -11,12 +11,11 @@ DECLARE BEGIN -- Проверяем, что демобаза установлена IF to_regclass('bookings.bookings') IS NULL THEN - RAISE EXCEPTION 'Таблица bookings.bookings не найдена. Сначала выполните make bookings-init.'; + RAISE EXCEPTION 'Таблица bookings.bookings не найдена. Сначала выполните make bookings-init или make bookings-generate.'; END IF; - -- В учебном стенде поддерживается только jobs=1, иначе генерация нестабильна. - IF v_jobs <> 1 THEN - RAISE EXCEPTION 'Поддерживается только bookings.jobs=1. Текущее значение: %. Установите BOOKINGS_JOBS=1 и выполните make bookings-init.', v_jobs; + IF v_jobs < 1 THEN + RAISE EXCEPTION 'bookings.jobs должен быть >= 1. Текущее значение: %.', v_jobs; END IF; -- Ищем последнюю сгенерированную дату @@ -36,13 +35,32 @@ BEGIN CALL generate(v_start_date, v_end_date, v_jobs); ELSE v_end_date := v_start_date + interval '1 day'; + + -- Убираем VACUUM-ивенты из очереди: генератор demodb кладёт + -- VACUUM ANALYZE всей БД каждую неделю модельного времени. + -- На 500k+ строках это занимает минуты и бессмысленно для +1 дня. + DELETE FROM gen.events WHERE type = 'VACUUM'; + CALL continue(v_end_date, v_jobs); END IF; - -- Ждём завершения фоновых джобов генератора, чтобы данные успели записаться - WHILE busy() LOOP - PERFORM pg_sleep(1); - END LOOP; + -- continue() делает TRUNCATE gen.stat_jobs → AccessExclusiveLock. + -- Без COMMIT воркеры не могут INSERT INTO gen.stat_jobs → deadlock. + COMMIT; + + -- Ждём завершения каждого воркера через dblink_is_busy(). + -- Раньше опрашивали busy() (pg_stat_activity + application_name), + -- но это ненадёжно: воркер может обрабатывать VACUUM ANALYZE (десятки минут), + -- или зависнуть в пустой очереди — а busy() не отличает «полезную работу» + -- от «бесконечного pg_sleep(1) при пустом gen.events». + -- dblink_is_busy() проверяет состояние конкретного dblink-соединения напрямую. + IF v_jobs > 1 THEN + FOR i IN 1 .. v_jobs LOOP + WHILE dblink_is_busy('job' || i) = 1 LOOP + PERFORM pg_sleep(1); + END LOOP; + END LOOP; + END IF; PERFORM dblink_disconnect(unnest(dblink_get_connections())); -- Если данных нет, останавливаемся с понятной ошибкой @@ -50,4 +68,12 @@ BEGIN IF v_bookings_cnt = 0 THEN RAISE EXCEPTION 'Генератор demodb завершился, но bookings.bookings пустая. Проверьте применение патчей и логи генератора.'; END IF; + + -- Генерация закончена — возвращаем synchronous_commit = on и сбрасываем + -- буферы на диск. Без этого docker compose down может убить PostgreSQL + -- до записи WAL → данные пропадут (особенно на WSL2). + ALTER DATABASE demo SET synchronous_commit = on; + SET synchronous_commit = on; END $$; + +CHECKPOINT; diff --git a/bookings/patches/install_connstr_no_hardcode.patch b/bookings/patches/install_connstr_no_hardcode.patch new file mode 100644 index 0000000..c123c2c --- /dev/null +++ b/bookings/patches/install_connstr_no_hardcode.patch @@ -0,0 +1,14 @@ +--- a/install.sql ++++ b/install.sql +@@ -24,8 +24,9 @@ + -- use UTC to avoid daylight-saving problems + ALTER DATABASE demo SET timezone = 'Etc/UTC'; + +--- connection string for dblink +-ALTER DATABASE demo SET gen.connstr = 'dbname=demo'; ++-- Строку подключения для dblink (gen.connstr) задаёт Makefile ++-- с credentials текущего пользователя БД. Не хардкодим здесь, ++-- иначе VACUUM и другие dblink-вызовы падают без пароля. + + -- airlines company name + ALTER DATABASE demo SET gen.airlines_name = 'PostgresPro'; diff --git a/bookings/seed/demo.sql.xz b/bookings/seed/demo.sql.xz new file mode 100644 index 0000000..e2cd760 Binary files /dev/null and b/bookings/seed/demo.sql.xz differ diff --git a/docker-compose.yml b/docker-compose.yml index f844480..d435488 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,7 @@ x-airflow-common-env: &airflow-env TZ: ${TZ:-Europe/Moscow} AIRFLOW__CORE__LOAD_EXAMPLES: "False" AIRFLOW__CORE__EXECUTOR: LocalExecutor + AIRFLOW__API__AUTH_BACKENDS: "airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session" AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB} AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW__WEBSERVER__SECRET_KEY} AIRFLOW_CONN_GREENPLUM_CONN: postgresql://${GP_USER}:${GP_PASSWORD}@greenplum:${GP_PORT:-5432}/${GP_DB} @@ -10,7 +11,6 @@ x-airflow-common-env: &airflow-env x-airflow-common-volumes: &airflow-volumes - ./airflow/dags:/opt/airflow/dags - ./sql:/sql:ro - - airflow_data:/opt/airflow/data - airflow_logs:/opt/airflow/logs x-airflow-common-depends: &airflow-depends @@ -109,7 +109,6 @@ services: volumes: - ./airflow/dags:/opt/airflow/dags - ./sql:/sql:ro - - airflow_data:/opt/airflow/data - airflow_logs:/opt/airflow/logs - ./airflow/requirements.txt:/opt/airflow/requirements.txt healthcheck: @@ -134,7 +133,6 @@ services: volumes: - ./airflow/dags:/opt/airflow/dags - ./sql:/sql:ro - - airflow_data:/opt/airflow/data - airflow_logs:/opt/airflow/logs - ./airflow/requirements.txt:/opt/airflow/requirements.txt depends_on: @@ -153,12 +151,11 @@ services: volumes: - ./airflow/dags:/opt/airflow/dags - ./sql:/sql:ro - - airflow_data:/opt/airflow/data - airflow_logs:/opt/airflow/logs command: > bash -lc " set -e; - mkdir -p /opt/airflow/data /opt/airflow/logs && chown -R airflow:root /opt/airflow/data /opt/airflow/logs; + mkdir -p /opt/airflow/logs && chown -R airflow:root /opt/airflow/logs; # Дожидаемся готовности БД ретрая миграции for i in {1..30}; do su -s /bin/bash airflow -c \"PATH='/home/airflow/.local/bin:$${PATH}' airflow db migrate\" && break || echo 'waiting for pgmeta' && sleep 3; @@ -174,5 +171,4 @@ volumes: pgmeta: bookings_data: greenplum_data: - airflow_data: airflow_logs: diff --git a/docs/README.md b/docs/README.md index 132330a..d3f79cd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,13 +5,41 @@ ## Быстрый путь (для менти) - [Быстрый старт и команды](../README.md) -- [Учебные задания](../educational-tasks.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) -## Технические детали (опционально) +## Дизайн (`design/`) + +- [Единые конвенции нейминга 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/`) - [Как устроен Docker-стенд (образы, Connections, переменные окружения)](stack.md) -- [PXF в этом проекте (проектная реализация)](internal/pxf_bookings.md) -- [Дизайн stg для bookings (черновик)](internal/bookings_stg_design.md) -- [Про время/UTC в bookings (черновик)](internal/bookings_tz.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) + +## Планы (`plans/`) + +Активные планы работ. После выполнения переносятся в `archive/`. + +## Архив (`archive/`) + +Выполненные планы, закрытые ревью. Ссылки внутри файлов могут быть устаревшими. diff --git a/docs/agent-dag-testing.md b/docs/agent-dag-testing.md new file mode 100644 index 0000000..35fcb2b --- /dev/null +++ b/docs/agent-dag-testing.md @@ -0,0 +1,89 @@ +# Тестирование DAG (гайд для AI-агентов) + +Этот документ описывает, как агенту взаимодействовать с Airflow (без UI) для точечной проверки и отладки DAG'ов в процессе разработки. + +## 0. Состояние среды (Предварительная проверка) + +Прежде чем тестировать DAG, убедитесь, что стек работает: +```bash +docker compose ps +``` +Если контейнеров нет, поднимите стек: `make up`. +Если исходная база данных пуста (например, после `make clean`), проинициализируйте её: `make bookings-init`. + +--- + +## 1. Быстрая проверка структуры графа (Локально) + +При любом изменении Python-кода DAG'а сначала проверьте, что он компилируется и структура графа корректна: +```bash +make test +``` +Это запустит smoke-тесты (`tests/test_dags_smoke.py`), которые проверят целостность всех DAG'ов без обращения к базе данных. + +--- + +## 2. Запуск конкретного DAG'а (REST API) + +Для тестирования загрузки данных запустите измененный DAG через REST API (базовый URL `http://localhost:8080/api/v1`, креды взять из `.venv`). + +**Шаг 2.1. Снять DAG с паузы (при старте стенда все DAG'и на паузе — этот шаг обязателен!):** +```bash +curl -s -X PATCH "http://localhost:8080/api/v1/dags/" \ + -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//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//dagRuns/" \ + -u admin:admin | jq '{state}' +``` +Повторяйте запрос, пока `state` не станет `success` или `failed`. + +--- + +## 3. Отладка упавших задач + +Если DAG перешел в статус `failed`, найдите упавшую задачу: + +**Шаг 3.1. Получить статусы всех задач:** +```bash +curl -s "http://localhost:8080/api/v1/dags//dagRuns//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 +``` + +*(Совет: ищите в логах слова `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'и в правильном порядке и проверит результаты. diff --git a/docs/archive/2026-01-18_bookings-stg-code-review.md b/docs/archive/2026-01-18_bookings-stg-code-review.md new file mode 100644 index 0000000..6fc2819 --- /dev/null +++ b/docs/archive/2026-01-18_bookings-stg-code-review.md @@ -0,0 +1,149 @@ +# Ревью решения (образец для студентов): `bookings-db` → `stg` в Greenplum + +Этот документ фиксирует рекомендации по улучшению учебного решения ETL (Airflow + Greenplum + PXF) +на основе ревью изменений ветки `chore/bookings-etl` (добавление полного STG слоя и пайплайна загрузки). + +Цель ревью — сделать решение **безоговорочно рекомендуемым** к изучению начинающими: +понятным, предсказуемым, с корректной терминологией и честными инженерными компромиссами. + +--- + +## 1) Сильные стороны решения (что уже хорошо и стоит сохранить) + +1. **Единый “шаблон” по таблицам** в `sql/stg/`: + - `{table}_ddl.sql` — создаёт `*_ext` и внутреннюю таблицу; + - `{table}_load.sql` — загружает данные; + - `{table}_dq.sql` — валидирует качество и останавливает пайплайн при проблемах. + + Это отличная учебная структура: студент быстро понимает, “где что лежит” и как добавлять новые таблицы. + +2. **DAG как оркестратор, SQL как логика**: + - `airflow/dags/bookings_to_gp_stage.py` и `airflow/dags/bookings_stg_ddl.py` используют `PostgresOperator` + и читают SQL с диска через `template_searchpath="/sql"`. + Это соответствует “канонической” модели: Airflow управляет шагами, а трансформации живут в SQL. + +3. **Понятные сообщения при падении DQ** (в большинстве скриптов): студенту легче дебажить. + +--- + +## 2) Критичные замечания (статус на текущий момент) + +### 2.1. Некорректные утверждения про MPP и co-location (статус: исправлено) + +В Greenplum производительность JOIN сильно зависит от распределения данных по сегментам. +Если ключ распределения двух таблиц совпадает с ключом JOIN — часто удаётся обойтись без перераспределения данных (motion). + +Раньше в некоторых DDL-комментариях обещалась co-location там, где её не будет. +Это педагогически опасно: студент запоминает неверную модель, а потом “не понимает”, почему запросы медленные. + +Что сделано: +- DDL-комментарии приведены к честной формулировке “ключ выбран так-то, но JOIN по другим ключам может требовать motion”. +- Исправлены места, где co-location заявлялась ошибочно (в т.ч. `routes`, `flights`, `segments`, `boarding_passes`). + +Файлы: `sql/stg/routes_ddl.sql`, `sql/stg/flights_ddl.sql`, `sql/stg/segments_ddl.sql`, `sql/stg/boarding_passes_ddl.sql`. + +### 2.2. DQ-проверки ссылочной целостности: “текущий батч” vs “вся история” (статус: исправлено) + +Часть DQ-скриптов проверяет наличие “родительских” записей в таблице **без фильтра `_load_id`**. +При append-only истории это может скрыть проблемы текущей загрузки: +родитель был загружен в прошлом батче → проверка пройдёт, даже если текущий батч родителя не загрузил. + +Что сделано: +- `routes_dq.sql`: проверка airports/airplanes стала батч-строгой (`_load_id = текущий батч`). +- `seats_dq.sql`: проверка airplanes стала батч-строгой (`_load_id = текущий батч`). +- `flights_dq.sql`: проверка routes стала батч-строгой (`_load_id = текущий батч`). + +Примечание (почему не везде `_load_id = текущий батч`): +- Если дочерняя таблица грузится инкрементом, то ссылки могут указывать на “исторические” записи, + загруженные в предыдущих батчах → для таких связей корректнее проверять “существует в STG вообще”. +- Для `boarding_passes` (full snapshot) ссылки на `tickets/segments` также проверяются по STG-истории, + потому что `tickets/segments` не перезагружаются полным снэпшотом каждый запуск. + +### 2.3. Smoke-тесты DAG’ов (статус: исправлено) + +Что сделано: +- Тесты усилены: теперь проверяются ключевые зависимости графа через `get_direct_relatives(upstream=False)` и “барьеры” через `get_flat_relatives(upstream=False)`. + +### 2.4. Документация по DAG (статус: синхронизировано) + +Что сделано: +- `docs/bookings_to_gp_stage.md` обновлён так, чтобы отражать текущий набор таблиц и шагов пайплайна. + +--- + +## 3) Рекомендации по качеству и читаемости (Clean Code для SQL и DAG) + +### 3.1. “Empty window” в инкременте: договориться о политике (fail vs skip) (статус: исправлено) + +Раньше поведение было разным: +- часть DQ-скриптов падала, если в окне инкремента 0 строк; +- `boarding_passes_dq.sql` делал `RAISE NOTICE` и `RETURN`. + +Обе стратегии допустимы, но в учебном решении важно выбрать одну и объяснить: +- **Fail** полезен, когда “ожидаем данные в каждом запуске” (например, учебный генератор должен добавлять день); +- **Skip** полезен, когда “окно может быть пустым и это нормально”. + +Выбранная политика для учебного стенда: +- Для инкрементальных таблиц (`bookings`, `tickets`, `flights`, `segments`) “пустое окно” **допустимо**: + DQ логирует `NOTICE` и завершает проверку, не падая. +- Для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) пустой источник считаем ошибкой: + это почти всегда признак проблем с PXF/источником. + +### 3.2. Комментарии в `*_load.sql`: точнее формулировать “идемпотентность”, а не “дедупликацию источника” (статус: исправлено) + +Типовой паттерн: +```sql +WHERE NOT EXISTS ( + SELECT 1 FROM stg.table WHERE _load_id = '{{ run_id }}' AND key = ext.key +); +``` + +Это в первую очередь защита от повторного запуска того же таска в рамках одного `_load_id` (retry), +а не “лечение” дублей в источнике. + +Что сделано: +- В `sql/stg/*_load.sql` комментарии приведены к формулировке “идемпотентность при повторном запуске/ретрае”. + +### 3.3. Проверка составных ключей: избегать склейки строк (статус: исправлено) + +Паттерн вида `COUNT(DISTINCT col1 || '|' || col2)` теоретически может давать коллизии (если в данных встречается разделитель). +В учебном стенде риск небольшой, но как “эталон” лучше показывать более безопасный подход. + +Что сделано: +- Заменили склейку строк на `COUNT(DISTINCT md5(ROW(col1, col2)::text))` в DQ‑скриптах для составных ключей. + Такой подход сохраняет DV‑стиль и убирает неоднозначность разделителей. + +--- + +## 4) Практические примеры “как улучшить” + +### 4.1. Батч-строгая ссылочная целостность (пример подхода) + +Если таблицы грузятся как snapshot в рамках батча, проверки можно сделать батч-строгими: +“в текущем батче ссылки указывают на строки текущего батча”. + +Идея (пример для routes → airports): +```sql +LEFT JOIN stg.airports AS a + ON r.departure_airport = a.airport_code + AND a._load_id = v_batch_id +``` + +### 4.2. Smoke-тест реального графа (минимальный полезный уровень) + +Вместо “таски существуют” лучше проверять ключевые зависимости: +```python +tickets_dq = dag.get_task("check_tickets_dq") +airports_load = dag.get_task("load_airports_to_stg") +assert airports_load in tickets_dq.get_direct_relatives(upstream=False) +``` + +--- + +## 5) Чек-лист “готово как эталон” + +- [x] В DDL-комментариях нет неверных обещаний про co-location/уникальность ключей. +- [x] Для DQ определена и описана политика “0 строк”: где fail, где skip. +- [x] DQ ссылочной целостности не маскирует проблемы текущего батча (batch-строгие проверки там, где это уместно). +- [x] `docs/bookings_to_gp_stage.md` соответствует фактическому DAG. +- [x] Smoke-тесты проверяют хотя бы критические зависимости графа. diff --git a/docs/archive/2026-03-04_dim-routes-denormalization.md b/docs/archive/2026-03-04_dim-routes-denormalization.md new file mode 100644 index 0000000..c72c6c8 --- /dev/null +++ b/docs/archive/2026-03-04_dim-routes-denormalization.md @@ -0,0 +1,205 @@ +# План: Денормализация dim_routes + упрощение route_performance + +> **Статус:** реализовано. + +## Контекст + +`dds.dim_routes` хранит только FK-ссылки (`departure_airport`, `arrival_airport`, `airplane_code`) без атрибутов (город, модель самолёта, кол-во мест). Из-за этого витрина `dm.route_performance` вынуждена делать 4-JOIN цепочку вместо одного JOIN к измерению. Это противоречит принципу Кимбалла (измерение должно быть «самодостаточным») и усложняет учебный материал. + +**Цель:** добавить в dim_routes 4 денормализованных колонки, упростить route_performance до 1 JOIN, показать студентам правильную Star Schema. + +--- + +## Файлы для изменения + +| # | Файл | Суть | +|---|---|---| +| 1 | `sql/dds/dim_routes_ddl.sql` | +4 колонки через ALTER TABLE | +| 2 | `sql/dds/dim_routes_load.sql` | JOIN к airports/airplanes при INSERT + refresh-шаг | +| 3 | `sql/dds/dim_routes_dq.sql` | NOT NULL проверка для новых колонок (current-срез) | +| 4 | `airflow/dags/bookings_to_gp_dds.py` | Зависимость: airports+airplanes DQ → routes load | +| 5 | `sql/dm/route_performance_load.sql` | Упрощение 4-JOIN → 1-JOIN | +| 6 | `tests/test_dags_smoke.py` | Новые assert для зависимости routes от airports/airplanes | +| 7 | `docs/internal/db_schema.md` | Обновить схему dim_routes | + +--- + +## Шаг 1. DDL — `sql/dds/dim_routes_ddl.sql` + +После существующего `COMMENT ON TABLE` добавить учебный комментарий и 4 ALTER TABLE: + +```sql +-- Денормализация атрибутов из SCD1-измерений (dim_airports, dim_airplanes). +-- +-- Учебный комментарий (Kimball Star Schema): +-- Измерение должно быть «самодостаточным»: один JOIN к dim_routes — +-- и аналитик видит маршрут, города, модель самолёта и кол-во мест. +-- +-- Эти колонки НЕ участвуют в hashdiff. Версия SCD2 фиксирует изменения +-- атрибутов маршрута (аэропорт, самолёт, расписание). Если изменится +-- название города — обновим отдельным refresh-шагом, не создавая новую версию. +ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS departure_city TEXT; +ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS arrival_city TEXT; +ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS airplane_model TEXT; +ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS total_seats INTEGER; +``` + +Колонки nullable — `ALTER TABLE ADD COLUMN ... NOT NULL` без DEFAULT упадёт на существующих строках. DQ проверит NOT NULL для current-среза. + +--- + +## Шаг 2. Load — `sql/dds/dim_routes_load.sql` + +Структура: 3 фазы вместо 2. + +**Фаза 1 (без изменений):** temp table + hashdiff из ods.routes, UPDATE закрытие версий. Hashdiff **не включает** денормализованные атрибуты. + +**Фаза 2 (изменён INSERT):** при вставке новой версии — LEFT JOIN к dim_airports (×2) и dim_airplanes для заполнения departure_city, arrival_city, airplane_model, total_seats. + +Конкретно: в INSERT (Statement 2, строки 59-107) добавить: +- В список колонок: `departure_city, arrival_city, airplane_model, total_seats` +- В SELECT: `dep.city, arr.city, air.model, air.total_seats` +- LEFT JOIN к dim_airports и dim_airplanes (LEFT — чтобы не терять маршруты при отсутствии аэропорта; DQ поймает) + +**Фаза 3 (новая):** refresh денормализованных атрибутов для всех current-версий: + +```sql +-- Фаза 3: Обновление денормализованных атрибутов (refresh). +-- Нужна для SCD1-изменений в dim_airports/dim_airplanes (напр. переименование города). +-- Обновляем ТОЛЬКО current-версии (valid_to IS NULL). +UPDATE dds.dim_routes AS d +SET departure_city = dep.city, + arrival_city = arr.city, + airplane_model = air.model, + total_seats = air.total_seats, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM dds.dim_airports AS dep, + dds.dim_airports AS arr, + dds.dim_airplanes AS air +WHERE d.valid_to IS NULL + AND dep.airport_bk = d.departure_airport + AND arr.airport_bk = d.arrival_airport + AND air.airplane_bk = d.airplane_code + AND ( + d.departure_city IS DISTINCT FROM dep.city + OR d.arrival_city IS DISTINCT FROM arr.city + OR d.airplane_model IS DISTINCT FROM air.model + OR d.total_seats IS DISTINCT FROM air.total_seats + ); +``` + +Этот же шаг при первом запуске заполнит колонки для существующих данных (backfill). + +--- + +## Шаг 3. DQ — `sql/dds/dim_routes_dq.sql` + +Перед финальным `RAISE NOTICE` добавить проверку: + +```sql +-- Денормализованные поля не пустые в текущих (актуальных) версиях. +-- Исторические версии могли быть загружены ДО добавления колонок — пропускаем. +SELECT COUNT(*) INTO v_null_count +FROM dds.dim_routes +WHERE valid_to IS NULL + AND (departure_city IS NULL OR arrival_city IS NULL + OR airplane_model IS NULL OR total_seats IS NULL); + +IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в current-срезе dim_routes найдены NULL денормализованные поля: %', + v_null_count; +END IF; +``` + +--- + +## Шаг 4. DAG — `airflow/dags/bookings_to_gp_dds.py` + +Убрать `load_dds_dim_routes` из параллельного fan-out, добавить зависимость от DQ airports и airplanes: + +```python +# Было: +dq_dds_dim_calendar >> [ + load_dds_dim_airports, + load_dds_dim_airplanes, + load_dds_dim_tariffs, + load_dds_dim_passengers, + load_dds_dim_routes, # параллельно +] + +# Стало: +dq_dds_dim_calendar >> [ + load_dds_dim_airports, + load_dds_dim_airplanes, + load_dds_dim_tariffs, + load_dds_dim_passengers, +] + +# dim_routes зависит от airports и airplanes (денормализация). +[dq_dds_dim_airports, dq_dds_dim_airplanes] >> load_dds_dim_routes +``` + +Транзитивная зависимость от calendar сохраняется через airports/airplanes. + +--- + +## Шаг 5. Витрина — `sql/dm/route_performance_load.sql` + +Упростить Шаг 3. Было 4 JOIN → станет 1 JOIN: + +```sql +FROM tmp_route_metrics m +JOIN dds.dim_routes r_curr + ON m.route_bk = r_curr.route_bk AND r_curr.valid_to IS NULL +``` + +SELECT использует `r_curr.departure_airport AS departure_airport_bk`, `r_curr.departure_city`, `r_curr.airplane_code AS airplane_bk`, `r_curr.airplane_model`, `r_curr.total_seats`. Добавить учебный комментарий: «Благодаря денормализации dim_routes — один JOIN вместо четырёх.» + +--- + +## Шаг 6. Тесты — `tests/test_dags_smoke.py` + +Добавить в `test_bookings_to_gp_dds_dag_structure`: + +```python +# dim_routes зависит от airports и airplanes (денормализация). +_assert_reachable(dag, "dq_dds_dim_airports", "load_dds_dim_routes") +_assert_reachable(dag, "dq_dds_dim_airplanes", "load_dds_dim_routes") +``` + +--- + +## Шаг 7. Документация — `docs/internal/db_schema.md` + +Обновить описание dim_routes: добавить 4 новые колонки, пометить «денормализовано из dim_airports/dim_airplanes». Обновить граф зависимостей DAG. + +--- + +## Что НЕ меняется + +- **fact_flight_sales_load.sql** — факт резолвит SK через свои JOIN-ы. Без изменений. +- **passenger_loyalty, monthly_overview** — используют только `route_bk` из dim_routes. +- **airport_traffic** — вообще не джойнит dim_routes. +- **hashdiff** — остаётся прежним (только атрибуты из ods.routes). + +--- + +## Верификация + +1. `make ddl-gp` — накатить DDL (ALTER TABLE добавит колонки) +2. Запустить DAG `bookings_to_gp_dds` — проверить: + - airports/airplanes грузятся ДО routes (Graph view) + - Фаза 3 (refresh) заполняет departure_city, arrival_city, airplane_model, total_seats + - DQ проходит без ошибок +3. Запустить DAG `bookings_to_gp_dm` — проверить: + - route_performance загружается с 1 JOIN + - Витрина содержит корректные города и модели самолётов +4. `make test` — smoke-тесты DAG проходят (включая новые assert'ы) +5. SQL-проверка: + ```sql + SELECT route_bk, departure_city, arrival_city, airplane_model, total_seats + FROM dds.dim_routes WHERE valid_to IS NULL LIMIT 5; + ``` diff --git a/docs/archive/2026-03-10_docs-restructuring.md b/docs/archive/2026-03-10_docs-restructuring.md new file mode 100644 index 0000000..faa040d --- /dev/null +++ b/docs/archive/2026-03-10_docs-restructuring.md @@ -0,0 +1,220 @@ +# План ревизии документации + +> Статус: **ЧЕРНОВИК v3** | Дата: 2026-03-10 +> Контекст: перед Этапом 2 (подготовка main) нужно навести порядок в docs/ + +--- + +## Проблемы + +- `docs/internal/` — свалка: дизайн-документы, планы, ревью, баг-трекеры, стандарты +- 3 архивных плана лежат рядом с живыми документами (неотличимы) +- 3 осиротевших документа (никто не ссылается) +- `educational-tasks.md` в корне — устарел (раздел 2.2 говорит «ODS/DDS/DM будут позже») +- 5 документов содержат устаревшие фрагменты +- `docs/README.md` не знает про несколько живых документов + +--- + +## Принципы + +- **Архив замораживается.** Файлы в `docs/archive/` не правим — ссылки внутри них + могут быть битыми, это ожидаемо. Они сохраняются как исторические артефакты. +- **Активные планы** живут в `docs/plans/`, после выполнения переезжают в `docs/archive/`. +- **Студенческий entry point** не должен исчезать: пока `analyst_spec.md` (Этап 3) + не создан, в `docs/assignment/` будет заглушка `README.md` со ссылкой на эталонный + срез для самостоятельного изучения. + +--- + +## Фаза 1. Структура каталогов + +Создать новые каталоги: + +``` +docs/design/ — дизайн-документы, стандарты, архитектура +docs/reference/ — техническая справка, баг-трекеры, бенчмарки +docs/plans/ — активные планы работ +docs/archive/ — выполненные планы, закрытые ревью +docs/assignment/ — заглушка README.md (подготовка для Этапа 3) +``` + +--- + +## Фаза 2. Перемещение файлов + +### В `docs/archive/` (4 файла) + +| Откуда | Файл | Причина | +|--------|-------|---------| +| корень | `educational-tasks.md` | Устарел, заменён `assignment_design.md` | +| `docs/internal/` | `stg_naming_unification_plan.md` | Выполнен | +| `docs/internal/` | `dim_routes_denormalization_plan.md` | Выполнен | +| `docs/internal/` | `bookings_stg_code_review.md` | Все замечания закрыты | + +### В `docs/plans/` (1 файл) + +| Откуда | Файл | Примечание | +|--------|-------|---------| +| `docs/internal/` | `docs_restructuring_plan.md` | Этот план — активный; после выполнения → `docs/archive/` | + +### В `docs/design/` (9 файлов из `docs/internal/`) + +| Файл | Роль | +|------|------| +| `PRD.md` | Стратегия продукта | +| `assignment_design.md` | Дизайн курсового задания | +| `naming_conventions.md` | Стандарт нейминга (единый источник) | +| `db_schema.md` | Схема БД всех слоёв DWH | +| `bookings_stg_design.md` | Дизайн STG-слоя | +| `bookings_ods_design.md` | Дизайн ODS-слоя | +| `bookings_dds_design.md` | Дизайн DDS-слоя | +| `bookings_dm_design.md` | Дизайн DM-слоя | +| `architecture_review.md` | Архитектурные решения (ADR) | + +### В `docs/reference/` (5 файлов из `docs/internal/`) + +| Файл | Роль | +|------|------| +| `pxf_bookings.md` | PXF: настройка, проблемы | +| `bookings_tz.md` | Источник bookings-db | +| `bookings_db_issues.md` | Известные проблемы bookings-db | +| `bookings_generation_benchmark.md` | Бенчмарк генерации | +| `qa-plan.md` | План отладки пайплайна | + +**Итог:** `docs/internal/` опустеет → удалить. + +--- + +## Фаза 3. Обновление перекрёстных ссылок + +### Категория A. Корневые и публичные файлы (ссылаются на `docs/internal/`) + +| Файл | Ссылок | Детали замен | +|------|--------|------| +| `AGENTS.md` | 2 | строки 15, 45: `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` | +| `TODO.md` | 6 | строки 6, 7, 43: → `docs/design/`; строка 121: → `docs/reference/pxf_bookings.md`; строка 128: → `docs/reference/bookings_db_issues.md`; строка 141: → `docs/archive/dim_routes_denormalization_plan.md` | +| `docs/README.md` | 7 | строки 18-24: все `internal/*` → `design/*` или `reference/*` | +| `docs/bookings_to_gp_stage.md` | 2 | строка 151: → `reference/pxf_bookings.md`; строка 156: → `archive/bookings_stg_code_review.md` | +| `docs/stack.md` | 1 | строка 85: → `reference/pxf_bookings.md` | + +### Категория B. Файлы внутри `docs/design/` и `docs/reference/` + +Полный реестр ссылок с `docs/internal/` в файлах, которые переедут в `design/` или +`reference/`. Все требуют обновления — либо кросс-каталожные пути, либо display-тексты. + +**B1. Кросс-каталожные ссылки (ссылка ведёт в другой каталог — путь сломается):** + +| Файл (→ design/) | Строка | Ссылка | Новый путь | +|------|--------|--------|------| +| `db_schema.md` | 426 | `bookings_tz.md` (отн.) | `../reference/bookings_tz.md` | +| `db_schema.md` | 427 | `pxf_bookings.md` (отн.) | `../reference/pxf_bookings.md` | +| `db_schema.md` | 425 | `bookings_stg_code_review.md` (отн.) | `../archive/bookings_stg_code_review.md` | +| `bookings_stg_design.md` | 5 | `docs/internal/bookings_tz.md` | `../reference/bookings_tz.md` | +| `bookings_stg_design.md` | 130 | `docs/internal/bookings_tz.md` | `../reference/bookings_tz.md` | +| `bookings_stg_design.md` | 131 | `docs/internal/pxf_bookings.md` | `../reference/pxf_bookings.md` | + +| Файл (→ reference/) | Строка | Ссылка | Новый путь | +|------|--------|--------|------| +| `qa-plan.md` | 10 | `docs/internal/bookings_db_issues.md` | `bookings_db_issues.md` (тот же каталог) | + +**B2. Внутрикаталожные, но с полным путём `docs/internal/...` (путь не сломается +для ссылок с относительным target, но display-текст устареет):** + +| Файл (→ design/) | Строки | Что обновить | +|------|--------|------| +| `PRD.md` | 209 | `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` | +| `bookings_ods_design.md` | 48 | display-текст `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` | +| `bookings_dds_design.md` | 8 | `docs/internal/bookings_ods_design.md` → `docs/design/bookings_ods_design.md` | +| `bookings_dds_design.md` | 185 | display-текст `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` | +| `bookings_dds_design.md` | 828-829 | `docs/internal/bookings_dds_design.md`, `docs/internal/db_schema.md` → `docs/design/...` | +| `bookings_dds_design.md` | 945 | `docs/internal/db_schema.md` → `docs/design/db_schema.md` | +| `bookings_dm_design.md` | 279 | `docs/internal/db_schema.md` → `docs/design/db_schema.md` | +| `bookings_dm_design.md` | 283, 291 | `docs/internal/bookings_dm_design.md` → `docs/design/bookings_dm_design.md` | +| `bookings_dm_design.md` | 395 | `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` | +| `db_schema.md` | 26 | display-текст `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` | +| `db_schema.md` | 421-423 | display-тексты `docs/internal/bookings_*_design.md` → `docs/design/...` | +| `architecture_review.md` | 70 | `docs/internal/bookings_dm_design.md` → `docs/design/bookings_dm_design.md` | +| `architecture_review.md` | 146 | `docs/internal/distribution_strategy.md` → **удалить путь** (файл не существует, оставить как текстовый backlog-пункт без ссылки) | + +### Категория C. Ссылки на `educational-tasks.md` + +| Файл | Действие | +|------|----------| +| `README.md` (корень) | Заменить ссылку на `educational-tasks.md` → `docs/assignment/` | +| `docs/README.md` | Заменить ссылку на `educational-tasks.md` → `assignment/` | + +### Категория D. Файлы в `docs/archive/` — НЕ ТРОГАЕМ + +Архивные файлы замораживаются. Ссылки внутри них могут быть битыми — это ожидаемо. + +### Комментарий в SQL + +`sql/dm/sales_report_ddl.sql` строка 52: `naming_conventions.md` (без пути) — +оставить как есть (комментарий, не ссылка; путь и так неточный). + +--- + +## Фаза 4. Актуализация содержания + +| Файл (новый путь) | Что сделать | +|------|-------------| +| `docs/design/architecture_review.md` | DM завершён (5/5 витрин), пометить выполненные P2; строка 146 — убрать путь к несуществующему `distribution_strategy.md`, оставить как текстовый backlog-пункт | +| `docs/design/db_schema.md` | Добавить DM-слой, убрать выполненный TODO | +| `TESTING.md` | Убрать артефакт «Docker-стенд не запускался» | +| `docs/dag_execution_order.md` | Добавить все 4 DDL DAG-а | +| `docs/reference/pxf_bookings.md` | Исправить нумерацию разделов (7→9→8→10 → последовательную) | + +--- + +## Фаза 5. Обновление индексов и заглушка assignment + +### `docs/README.md` + +Переписать структуру: разделы по каталогам (`design/`, `reference/`, `plans/`, +`archive/`, `assignment/`). Включить ранее пропущенные документы: `db_schema.md`, +`dag_execution_order.md`, `e2e-etl-test-protocol.md`, `agent-dag-testing.md`. + +### `README.md` (корень) + +Заменить ссылку на `educational-tasks.md` → `docs/assignment/`, проверить остальные. + +### `docs/assignment/README.md` (новый файл) + +Временная заглушка: +- Указание, что курсовые задания появятся в Этапе 3 +- Ссылка на эталонный срез (DAG-и STG→ODS→DDS→DM) для самостоятельного изучения +- Ссылка на `docs/design/assignment_design.md` для менторов + +--- + +## Фаза 6. Финальная проверка + +Шаги выполняются строго по порядку: + +1. [ ] `make test` — тесты проходят +2. [ ] `make lint` — стиль кода +3. [ ] Удалить пустой каталог `docs/internal/` +4. [ ] Перенести план из `docs/plans/` в `docs/archive/docs_restructuring_plan.md` +5. [ ] grep по `internal/` в живых .md файлах (`rg --glob '!docs/archive/*'`) — нет битых ссылок +6. [ ] grep по `educational-tasks` в живых .md файлах (`rg --glob '!docs/archive/*'`) — нет битых ссылок + +--- + +## Порядок выполнения + +Фазы 1→2→3 делаются вместе (иначе ссылки будут битыми). +Фаза 4 — независима, можно параллельно. +Фаза 5 — после всех перемещений. +Фаза 6 — в конце. + +--- + +## Что это даёт следующим этапам + +| Этап | Как помогает | +|------|-------------| +| **Этап 2** (подготовка main) | Чистая структура — понятно, что удалять, что оставлять | +| **Этап 3** (ТЗ от аналитика) | Готовый каталог `docs/assignment/` с заглушкой, место для `analyst_spec.md` | +| **Этап 4** (валидационный DAG) | `docs/reference/qa-plan.md` — рядом с другими справочными | +| **Этап 5** (ветка solution) | Архив отделён — не попадёт в ветку solution | diff --git a/educational-tasks.md b/docs/archive/2026-03-10_educational-tasks.md similarity index 60% rename from educational-tasks.md rename to docs/archive/2026-03-10_educational-tasks.md index 6d1bd48..1c78fd9 100644 --- a/educational-tasks.md +++ b/docs/archive/2026-03-10_educational-tasks.md @@ -1,63 +1,18 @@ # Учебные задания по стенду Этот документ собирает в одном месте задания для менти. -Он разбит на блоки: от базовой работы с CSV‑pipeline до более продвинутого сценария с демо‑БД bookings и слоем STG в Greenplum. +Он разбит на блоки: от архитектуры Greenplum и демо‑БД bookings до реализации аналитических слоев DWH. -Если вы только начинаете, выполняйте задания по порядку. К разделу про bookings можно вернуться позже. +Если вы только начинаете, выполняйте задания по порядку. --- -## 1. Базовый CSV‑pipeline (csv_to_greenplum) - -Основная цель этого блока — понять, как устроен простой ETL: генерация данных через pandas, сохранение в CSV и загрузка в Greenplum. - -### 1.1. Разбор готового pipeline - -1. Найдите DAG `csv_to_greenplum` в `airflow/dags/csv_to_greenplum.py`. -2. Ответьте себе на вопросы (можно коротко в отдельном файле/блокноте): - - какие задачи (tasks) входят в DAG и что делает каждая из них; - - какие таблицы создаются в Greenplum; - - где физически лежат CSV‑файлы; - - какие параметры управляют размером датасета. -3. Поднимите стенд и запустите DAG: - - `make up` (Airflow инициализируется автоматически при первом старте) - - включите и запустите DAG `csv_to_greenplum` в Airflow UI. -4. Проверьте результат в Greenplum: - - `make gp-psql` - - `SELECT COUNT(*) FROM public.orders;` - - `SELECT * FROM public.orders LIMIT 5;` - -### 1.2. Изменение параметров генерации - -1. Найдите, где задаётся количество строк для генерации (`CSV_ROWS` в `.env` и параметр в DAG). -2. Поставьте другое значение и перезапустите DAG: - - оцените, как изменилось количество строк в `public.orders`; - - убедитесь, что пайплайн по‑прежнему работает без ошибок. -3. Попробуйте изменить схему данных (добавить колонку в CSV и таблицу в Greenplum): - - добавьте новую колонку в генерацию pandas; - - обновите DDL/SQL, чтобы колонка появилась в таблице `public.orders`; - - перезапустите DAG и убедитесь, что новая колонка заполняется. - -### 1.3. Собственные проверки качества данных - -1. Найдите DAG `csv_to_greenplum_dq` в `airflow/dags/csv_to_greenplum_dq.py`. -2. Посмотрите, какие проверки уже реализованы (наличие таблицы, схема, дубликаты). -3. Добавьте ещё одну простую проверку, например: - - проверка, что в таблице `public.orders` не больше N строк; - - проверка, что поле (например, `order_price`) не содержит отрицательных значений; - - проверка, что нет строк с `NULL` в ключевых колонках. -4. Запустите DAG `csv_to_greenplum_dq` и убедитесь, что: - - новая проверка проходит на «хороших» данных; - - при нарушении условия DAG падает с понятной ошибкой. - ---- - -## 2. Greenplum и модель данных (введение) +## 1. Greenplum и модель данных (введение) В следующих заданиях мы будем опираться на демо‑БД bookings (Postgres) и слой STG в Greenplum. На этом этапе достаточно бегло посмотреть на структуру и понять общую идею, детальная проработка пойдёт позже. -### 2.1. Знакомство с демо‑БД bookings +### 1.1. Знакомство с демо‑БД bookings 1. Прочитайте `bookings/README.md` — какие сервисы и команды относятся к демобазе. 2. Поднимите стенд и выполните: @@ -70,18 +25,18 @@ - какие типы колонок используются; - какие поля выглядят как ключи, даты, суммы. -### 2.2. Знакомство с STG в Greenplum +### 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`; - - зачем нужны тех.колонки `src_created_at_ts`, `load_dttm`, `batch_id`; + - зачем нужны тех.колонки `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). -### 2.3. Как генерируются учебные данные bookings +### 1.3. Как генерируются учебные данные bookings 1. Откройте файл `bookings/generate_next_day.sql` и ответьте себе на вопросы: - с какой даты начинается генерация данных (посмотрите на GUC `bookings.start_date` и переменную `v_start_cfg`); @@ -94,17 +49,17 @@ - логическая дата запуска DAG (`{{ ds }}`) не влияет на выбор дня генерации; - скрипт всегда смотрит на `max(book_date)` и добавляет **следующий** день (или несколько стартовых дней, если база пуста). 4. Сделайте вывод: генератор всегда «шагает» по датам вперёд от максимальной даты, поэтому: - - при `make bookings-init` вы получаете `BOOKINGS_INIT_DAYS` дней начиная с `BOOKINGS_START_DATE`; + - при `make bookings-init` вы получаете готовые данные из seed-дампа (при `make bookings-generate` генератор создаст `BOOKINGS_INIT_DAYS` дней начиная с `BOOKINGS_START_DATE`); - при последующих вызовах (`make bookings-generate-day` или DAG) добавляется ровно один новый день. --- -## 3. DAG bookings_to_gp_stage (заготовка заданий) +## 2. DAG bookings_to_gp_stage (заготовка заданий) Этот DAG показывает путь данных от демо‑БД bookings в Postgres до сырого слоя STG в Greenplum. Сейчас он уже реализован как учебный пример, а в будущем вокруг него появятся отдельные задания по моделированию DWH. -### 3.1. Что есть сейчас +### 2.1. Что есть сейчас 1. Откройте `airflow/dags/bookings_to_gp_stage.py`. 2. Найдите в коде ссылки на SQL‑файлы: @@ -116,14 +71,14 @@ - загрузка инкремента в `stg.bookings`; - проверка количества строк между источником и STG. 4. Обратите внимание, как в DAG используется логическая дата запуска: - - `{{ run_id }}` используется как `batch_id` — метка загрузки в таблице `stg.bookings` для конкретного запуска; + - `{{ run_id }}` используется как `_load_id` — метка загрузки в таблице `stg.bookings` для конкретного запуска; - сами даты данных (какие дни есть в `bookings.bookings`) определяются генератором по `max(book_date)`, а не по `ds`. На этом этапе достаточно понять общую цепочку. Детальные задания по переработке модели данных и построению ODS/DDS/DM слоёв будут добавлены позже. -### 3.2. Идеи для будущих заданий (черновик) +### 2.2. Идеи для будущих заданий (черновик) -> Ниже — набросок задач, к которым мы вернёмся, когда базовые темы по Airflow и CSV‑pipeline будут освоены. +> Ниже — набросок задач, к которым мы вернёмся, когда базовые темы по Airflow будут освоены. Планируемые направления: diff --git a/docs/archive/2026-03-10_stg-naming-unification.md b/docs/archive/2026-03-10_stg-naming-unification.md new file mode 100644 index 0000000..bca5fb9 --- /dev/null +++ b/docs/archive/2026-03-10_stg-naming-unification.md @@ -0,0 +1,118 @@ +# Унификация нейминга служебных полей в STG + +## Контекст + +В STG-слое используются legacy-имена служебных полей (`batch_id`, `load_dttm`, `src_created_at_ts`), а начиная с ODS — каноничные (`_load_id`, `_load_ts`, `event_ts`). Студент видит разные имена для одного понятия. Цель — привести STG к канону, убрав расхождение. + +> **Breaking change (dev-only).** Это ломающее переименование колонок. Миграционный шаг (ALTER TABLE … RENAME COLUMN) не предусмотрен. DDL-файлы используют `CREATE TABLE IF NOT EXISTS`, поэтому сами по себе они не пересоздадут существующие таблицы с новыми именами колонок. План предполагает заранее пересозданную среду (например, `make down && make up`) или ручной `DROP TABLE` / `DROP SCHEMA` перед `make ddl-gp`. Обратная совместимость не обеспечивается. + +## Маппинг + +| Legacy (STG сейчас) | Канон (ODS/DDS/DM) | +|---|---| +| `batch_id` | `_load_id` | +| `load_dttm` | `_load_ts` | +| `src_created_at_ts` | `event_ts` | + +### Оговорка про `event_ts` в snapshot-справочниках + +В транзакционных STG-таблицах (bookings, tickets, flights, segments, boarding_passes) поле `src_created_at_ts` действительно хранит время события из источника — переименование в `event_ts` семантически точно. + +В snapshot-справочниках (airports, airplanes, routes, seats) это поле заполняется `now()` при загрузке, т.е. по факту это ещё одно load-time, а не время события. Тем не менее мы сохраняем единое имя `event_ts` как **учебное упрощение** — ради консистентной структуры STG-таблиц. Это зафиксировано как осознанный trade-off: единообразие важнее семантической точности в справочниках. В `naming_conventions.md` нужно добавить соответствующую оговорку (раздел 4, «Time Rule»). + +## Что НЕ переименовываем + +- PL/pgSQL переменная `v_batch_id` — это локальная переменная, не колонка +- Python-функция `_resolve_stg_batch_id`, переменная `stg_batch_id`, task_id `resolve_stg_batch_id` — это Python/Airflow-идентификаторы +- XCom-ключи, ссылающиеся на task_id + +## Порядок выполнения + +### Шаг 1: STG DDL (9 файлов) + +`sql/stg/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_ddl.sql` + +В каждом: `batch_id` → `_load_id`, `load_dttm` → `_load_ts`, `src_created_at_ts` → `event_ts`. + +### Шаг 2: STG Load (9 файлов) + +`sql/stg/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_load.sql` + +INSERT-списки, SELECT, WHERE, комментарии — те же 3 замены. + +### Шаг 3: STG DQ (9 файлов) + +`sql/stg/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_dq.sql` + +WHERE-условия (`batch_id = v_batch_id` → `_load_id = v_batch_id`), RAISE-сообщения, комментарии. + +### Шаг 4: ODS Load (9 файлов) + +Два подтипа — обрабатывать по-разному. + +#### 4a: Транзакционные таблицы (5 файлов) + +`sql/ods/{bookings,tickets,flights,segments,boarding_passes}_load.sql` + +SELECT из STG: `s.batch_id` → `s._load_id`, `s.load_dttm` → `s._load_ts`, `s.src_created_at_ts` → `s.event_ts`. Убрать лишние алиасы (`s.src_created_at_ts AS event_ts` → просто `s.event_ts`). + +#### 4b: Snapshot-справочники (4 файла) + +`sql/ods/{airports,airplanes,routes,seats}_load.sql` + +Здесь `event_ts` отсутствует в целевой ODS-таблице — менять только ссылки на STG-колонки: `s.batch_id` → `s._load_id`, `s.load_dttm` → `s._load_ts`, `s.src_created_at_ts` → `s.event_ts` (только в ORDER BY / WHERE, где они читают из STG). ODS-колонка `_load_ts` по-прежнему заполняется через `now()`, это не меняется. + +### Шаг 5: ODS DQ (9 файлов) + +`sql/ods/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_dq.sql` + +`WHERE batch_id =` → `WHERE _load_id =`, RAISE-сообщения. + +### Шаг 6: DAG-файлы (2 файла) + +- `airflow/dags/bookings_to_gp_stage.py` — комментарий про `batch_id` +- `airflow/dags/bookings_to_gp_ods.py` — встроенный SQL-запрос резолвера: все `batch_id` как колонка → `_load_id`, `load_dttm` → `_load_ts`. Python-имена не трогаем. + +### Шаг 7: Тесты (3 файла) + +- `tests/test_ods_snapshot_integration.py` — inline DDL и INSERT в тестах +- `tests/test_dags_smoke.py` — комментарии +- `tests/test_ods_sql_contract.py` — docstring + +### Шаг 8: Документация (~15 файлов) + +- `docs/internal/naming_conventions.md` — убрать legacy-исключение (секция 5/STG), убрать переходный маппинг (секция 6), добавить оговорку про `event_ts` в snapshot-справочниках (секция 4) +- `docs/internal/db_schema.md` — описания STG-полей +- `docs/internal/bookings_stg_design.md` — дизайн STG +- `docs/internal/bookings_ods_design.md` — маппинг STG→ODS, SQL-примеры +- `docs/internal/qa-plan.md` — SQL-запросы проверок +- `docs/internal/architecture_review.md` — архитектурные заметки +- `docs/internal/bookings_stg_code_review.md` — код-ревью +- `docs/bookings_to_gp_stage.md` — описание STG DAG, примеры полей +- `docs/bookings_to_gp_ods.md` — описание ODS DAG +- `docs/dag_execution_order.md` — порядок выполнения DAG +- `educational-tasks.md` — учебные задания +- `README.md` — SQL-примеры в README +- `TESTING.md` — чек-лист тестирования + +### Шаг 9: Верификация + +```bash +# 1. Проверить SQL и Python — не должно быть колонок batch_id +# (допустимы только: v_batch_id, stg_batch_id, resolve_stg_batch_id) +grep -rn 'batch_id' sql/stg/ sql/ods/ airflow/dags/ tests/ --include='*.sql' --include='*.py' + +# 2. Ноль совпадений по старым именам в коде +grep -rn 'load_dttm' sql/ airflow/dags/ tests/ --include='*.sql' --include='*.py' +grep -rn 'src_created_at_ts' sql/ airflow/dags/ tests/ --include='*.sql' --include='*.py' + +# 3. Проверить документацию — не должно быть старых имён как актуальных +# (допустимы упоминания в историческом контексте) +grep -rn 'batch_id\|load_dttm\|src_created_at_ts' docs/ educational-tasks.md README.md TESTING.md + +# 4. Тесты и линтер +make test +make fmt && make lint +``` + +## Итого: ~55 файлов, ~3 механические замены в каждом diff --git a/docs/archive/2026-03-11_docs-etl-quality.md b/docs/archive/2026-03-11_docs-etl-quality.md new file mode 100644 index 0000000..51545da --- /dev/null +++ b/docs/archive/2026-03-11_docs-etl-quality.md @@ -0,0 +1,88 @@ +# План: выравнивание качества документации ETL DAG'ов + +## Контекст + +Документ `bookings_to_gp_stage.md` — эталон: пошаговый разбор задач, ASCII-граф, +ссылки на SQL-файлы, описание edge cases. Три остальных документа +(`_ods`, `_dds`, `_dm`) значительно беднее. Цель — подтянуть их до того же уровня. + +## Единый шаблон секций (целевая структура) + +Каждый документ должен содержать: + +1. **Заголовок + вводный абзац** (что за DAG, какой слой, зачем) +2. **Что делает DAG** (буллеты, краткое описание) +3. **Что должно быть готово** (prerequisites) +4. **Как запустить** (UI + опциональные параметры) +5. **Граф зависимостей** (ASCII-диаграмма, не буллет-лист) +6. **Как это работает внутри (по шагам)** ← ГЛАВНОЕ ДОБАВЛЕНИЕ + - Пронумерованные шаги: task_id → SQL-файл → что делает → паттерн (SCD1/SCD2/rebuild/HWM) + - Учебные пояснения к нетривиальным паттернам +7. **Как проверить результат** (SQL-запросы) +8. **Типичные ошибки** (уже есть, оставляем) + +## Что именно добавить/исправить в каждом документе + +### A. `bookings_to_gp_ods.md` + +**Текущее состояние:** 95 строк, нет пошагового разбора, нет ASCII-графа, нет SQL-путей. + +| # | Что сделать | Детали | +|---|-------------|--------| +| A1 | ASCII-граф зависимостей | Заменить буллет-лист на диаграмму (как в stage). Показать параллельные ветки airports/airplanes, схождение на routes/seats, цепочку flights→segments→boarding_passes | +| A2 | Секция "Как это работает внутри" | 10 шагов: resolve_stg_batch_id (Python, INTERSECT-логика), затем 9 пар load→dq с указанием SQL-файлов | +| A3 | Пояснить паттерн SCD1 UPSERT | Кратко: TEMP TABLE → UPDATE (IS DISTINCT FROM) → INSERT. Одного абзаца достаточно, потом ссылка "паттерн одинаков для всех 9 таблиц" | +| A4 | Описать разницу snapshot vs HWM | Snapshot-справочники фильтруются по `stg_batch_id`; транзакционные таблицы — по HWM (`_load_ts`). Объяснить почему (чтобы не терять инкременты при повторных запусках STG) | +| A5 | Edge case: пустой батч | Для инкрементальных таблиц допустим; для snapshot — нет | + +**Ожидаемый объём:** ~140–160 строк. + +### B. `bookings_to_gp_dds.md` + +**Текущее состояние:** 73 строки — самый бедный документ. Нет пошаговости, нет ASCII-графа, SCD2 не объяснён. + +| # | Что сделать | Детали | +|---|-------------|--------| +| B1 | ASCII-граф зависимостей | calendar → параллельно 4 SCD1-измерения + dim_routes (после airports+airplanes) → fact (после всех dims) → summary | +| B2 | Секция "Как это работает внутри" | 8 шагов: calendar (rebuild), 4×SCD1-измерения, dim_routes (SCD2), fact_flight_sales, summary | +| B3 | Объяснить SCD2 для dim_routes | Учебный блок: hashdiff (MD5), закрытие старых версий, вставка новых, point-in-time valid_from. Это ключевой паттерн DDS — заслуживает 10–15 строк | +| B4 | Объяснить Phase 3 (денормализация) | Обновление SCD1-атрибутов (города, модель) во ВСЕХ версиях dim_routes. Зачем: чтобы не хранить устаревшие названия городов | +| B5 | Объяснить late-arriving dimensions | LEFT JOIN в fact_flight_sales: факт может прийти раньше справочника. 3–5 строк | +| B6 | Объяснить point-in-time join | Как факт привязывается к правильной версии SCD2-маршрута по дате рейса | +| B7 | Edge case: генерация SK | MAX() + ROW_NUMBER() безопасна только при concurrency=1 | + +**Ожидаемый объём:** ~160–180 строк. + +### C. `bookings_to_gp_dm.md` + +**Текущее состояние:** 80 строк. ASCII-граф есть (хорошо!), описание стратегий загрузки есть (хорошо!), но нет пошагового разбора с SQL-файлами. + +| # | Что сделать | Детали | +|---|-------------|--------| +| C1 | Секция "Как это работает внутри" | 5 шагов (по одному на витрину) + start_dm + finish_dm_summary. Для каждой витрины: task_id → SQL-файл → паттерн → зерно (grain) | +| C2 | Расширить описание sales_report | HWM-паттерн: какие даты пересчитываются, NULLIF-guard для boarding_rate, автоматическая "догонка" при первичной загрузке | +| C3 | Расширить описание route_performance | Почему Full Rebuild: AO Column (нет UPDATE/DELETE), таблица маленькая. 3-шаговый паттерн: TRUNCATE → агрегация по route_bk → JOIN с текущей версией SCD2 | +| C4 | Добавить grain для каждой витрины | sales_report: (flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk); route_performance: route_bk; и т.д. | +| C5 | Добавить SQL-пути к задачам | Сейчас нигде не указаны пути к SQL-файлам | + +**Ожидаемый объём:** ~140–160 строк. + +### D. Мелкие правки в `bookings_to_gp_stage.md` (опционально) + +| # | Что сделать | Детали | +|---|-------------|--------| +| D1 | Убедиться в консистентности шаблона | Если в ходе работы над ODS/DDS/DM выработается чуть лучшая структура — привести stage к тому же формату (только структурные правки, контент не менять) | + +## Порядок работы + +1. **ODS** (средняя сложность, знакомый паттерн SCD1) +2. **DDS** (наибольшая учебная ценность — SCD2, late-arriving dims) +3. **DM** (наименьший объём правок — ASCII-граф уже есть) +4. **Stage** — только если нужна косметика для консистентности + +## Принципы + +- Не раздувать: целевой объём каждого документа — 140–180 строк (stage = 157). +- Учебная ценность > полнота: объяснять «почему», а не перечислять все колонки. +- SQL-пути обязательны — это главный навигационный инструмент для студента. +- Один паттерн объясняем один раз подробно, дальше ссылаемся: "паттерн аналогичен X". diff --git a/docs/archive/2026-03-11_routes-to-reference.md b/docs/archive/2026-03-11_routes-to-reference.md new file mode 100644 index 0000000..cfd3e54 --- /dev/null +++ b/docs/archive/2026-03-11_routes-to-reference.md @@ -0,0 +1,503 @@ +# Plan: Перенос STG в эталон, ODS airplanes+seats — студенту + +> Версия: 4 (после третьего ревью ChatGPT 5.4) +> Дата: 2026-03-11 + +> **Статус выполнения (2026-03-12):** +> - Секция «Сейчас (на chore/bookings-etl)» — ✅ выполнена (п.1, п.6, п.7, п.9) +> - Секция «Этап 3 (валидационный DAG)» — ✅ выполнена (см. `docs/archive/2026-03-12_validation-dag.md`) +> - Секция «Этап 4 (подготовка main + solution)» — ⬜ не выполнена. +> Детальные спецификации кода (п.2–5) и документации (п.8, п.10–11) остаются +> актуальным справочником. Порядок выполнения и стратегия веток — +> в новом плане `docs/plans/2026-03-12_main-solution-split.md`. + +## Контекст и мотивация + +### Обнаруженная проблема + +При подготовке к Этапу 4 (раскладка по веткам main / solution) обнаружен **конфликт +между текущим дизайном БД** (`docs/design/db_schema.md`, `docs/design/bookings_dds_design.md`) +**и планом курсового задания** (`docs/design/assignment_design.md`). + +Суть конфликта: эталонный пайплайн (который должен работать «из коробки» на main-ветке +для студента) **неработоспособен**, если студент ещё не реализовал ни одного задания. +Три DDS-измерения (`dim_routes`, `dim_passengers`, `dim_airplanes`) отнесены к заданию +студента, но эталонная фактовая таблица `fact_flight_sales` зависит от них через +цепочку LEFT JOIN. Без этих измерений факт загружается с массовыми NULL в ключевых FK, +а DQ-проверки блокируют pipeline. Эталонные витрины (`sales_report`, `airport_traffic`) +становятся бесполезными. + +Проблема усугубляется архитектурой batch resolver ODS DAG и тестовыми контрактами, +которые требуют данных во **всех** snapshot-таблицах STG. + +Ниже — детальный разбор и выбранное решение. + +### Фактовая таблица: зависимость от студенческих измерений + +`fact_flight_sales` зависит от **всех 7 измерений** DDS, +включая 3 студенческих: `dim_routes`, `dim_passengers`, `dim_airplanes`. + +Критическая цепочка: аэропорты в факте разрешаются **через `dim_routes`**: + +``` +flt.route_no → dim_routes.route_bk → rte.departure_airport → dim_airports.airport_bk → airport_sk +``` + +Если `dim_routes` пуст (студент ещё не реализовал), то **NULL** получают не только +`route_sk`, но и оба `airport_sk` и `airplane_sk`. Эталонная витрина `sales_report` +становится бесполезной. + +Причина: `ods.flights` содержит только `route_no`, но не `departure_airport` / +`arrival_airport` / `aircraft_code`. Эти поля доступны только через таблицу `routes`. + +### Почему нельзя просто использовать DDL-заглушки + +Если `dim_routes` — пустая заглушка, все LEFT JOIN через неё возвращают NULL. +Факт загружается, но: + +- `departure_airport_sk = NULL` → `sales_report` не может группировать по аэропортам +- `arrival_airport_sk = NULL` → `airport_traffic` не может считать трафик +- UPDATE факта **не перезаписывает SK** (by design, строка 4-5 load-скрипта) → + NULL-и остаются навсегда до TRUNCATE + перезагрузки + +### Дополнительные архитектурные ограничения (из ревью) + +1. **Fact DQ блокирует загрузку** (`fact_flight_sales_dq.sql`): + - Строка 71: `passenger_sk IS NULL` → **hard fail** (100% NULL → exception) + - Строка 98: `route_sk / airplane_sk IS NULL` → при >1% строк → **exception** + - На main с пустыми студенческими dims — 100% NULL → pipeline падает + +2. **Batch resolver ODS DAG** (`bookings_to_gp_ods.py:59-77`): + - `INTERSECT` по `stg.airports`, `stg.airplanes`, `stg.routes`, `stg.seats` + - Если **любая** STG-таблица пуста — общего batch нет → весь ODS pipeline падает + - Контракт зафиксирован тестом `test_ods_sql_contract.py:30` + +### Выбранное решение + +**Весь STG-слой — эталонный** (все 9 таблиц загружаются reference-кодом). +Это гарантирует: +- Batch resolver всегда находит согласованный batch (все 4 snapshot-таблицы заполнены) +- DAG-зависимости и smoke-тесты STG не требуют изменений +- Никакого расхождения batch-контракта между main и solution + +**ODS: `airplanes` + `seats` остаются заданием студента** (TRUNCATE+INSERT практика). + +**DDS: `dim_airplanes`, `dim_passengers`, `dim_routes`** — задание студента (DDL-заглушки). + +**Fact lookup**: аэропорты через `ods.routes` (эталон); `airplane_sk` остаётся +point-in-time через `dim_routes` (NULL на main — допустимо). + +**Fact DQ**: на main ослабить проверки студенческих SK. + +### Что теряет студент (и почему это приемлемо) + +| Потеря | Компенсация | +|--------|-------------| +| STG-практика (PXF external tables) | Изучает эталонный STG-код; отдельный PXF-практикум в бэклоге (`assignment_design.md`, раздел 6) | +| `ods.routes` (TRUNCATE+INSERT) | Остаются `ods.airplanes` + `ods.seats` — тот же паттерн | + +Ключевые элементы задания **сохранены**: +- ODS: 2 таблицы (airplanes, seats) — практика TRUNCATE+INSERT +- DDS: `dim_airplanes` (SCD1), `dim_passengers` (SCD1), **`dim_routes` (SCD2)** — ключевой вызов +- DM: 4 витрины разной сложности (от простой к сложной) + +### Пересчёт факта после реализации студентом + +После реализации всех измерений студенту нужно: +1. Запустить загрузку DDS-измерений (dim_airplanes, dim_passengers, dim_routes) +2. `TRUNCATE dds.fact_flight_sales;` +3. Перезапустить загрузку факта → теперь все SK заполнены +4. Перезапустить DM-витрины + +Это стандартная практика при late-arriving dimensions и хорошая обучающая точка. + +### airport_traffic — студенческое задание + +`airport_traffic` архитектурно не зависит от студенческих dims (использует только +`calendar_sk`, `departure_airport_sk`, `arrival_airport_sk`). Но это **не значит**, +что витрина готова: SQL витрины пишет студент. Данные в факте есть (эталон), +студент пишет агрегирующий SQL. Статус: **задание студента** (как и остальные 3 DM). + +--- + +## Матрица зависимостей DM-витрин + +| DM витрина | calendar | airports | tariffs | routes | passengers | airplanes | Работает без студ. dims? | +|---|---|---|---|---|---|---|---| +| `sales_report` (эталон) | + | + | + | — | — | — | **ДА** | +| `airport_traffic` (студент) | + | + | — | — | — | — | Данные есть, SQL пишет студент | +| `route_performance` (студент) | + | — | — | **+** | — | — | Нет | +| `monthly_overview` (студент) | + | — | — | **+** | **+** | **+** | Нет | +| `passenger_loyalty` (студент) | + | — | + | **+** | **+** | — | Нет | + +--- + +## Изменения (код) + +### 1. `sql/dds/fact_flight_sales_load.sql` — lookup аэропортов через ods.routes + +**Ветка:** chore/bookings-etl (сейчас) + main (Этап 4) + +**Текущее состояние** (строки 51-62): +```sql +LEFT JOIN dds.dim_routes AS rte + ON rte.route_bk = flt.route_no + AND flt.scheduled_departure::DATE >= rte.valid_from + AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to) +... +LEFT JOIN dds.dim_airports AS dep + ON dep.airport_bk = rte.departure_airport -- через dim_routes! +LEFT JOIN dds.dim_airports AS arr + ON arr.airport_bk = rte.arrival_airport -- через dim_routes! +LEFT JOIN dds.dim_airplanes AS ap + ON ap.airplane_bk = rte.airplane_code -- через dim_routes! +``` + +**После изменения:** +```sql +-- Учебный комментарий: Airport lookup через ods.routes (эталонный справочник), +-- а не через dds.dim_routes (студенческое задание SCD2). +-- Это архитектурное решение: эталонный пайплайн работает независимо от студенческого кода. +-- ROW_NUMBER по validity DESC: выбираем актуальную версию расписания маршрута. +-- Аэропорты вылета/прилёта одинаковы во всех версиях одного route_no. +LEFT JOIN ( + SELECT route_no, departure_airport, arrival_airport + FROM ( + SELECT route_no, departure_airport, arrival_airport, + ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn + FROM ods.routes + ) ranked + WHERE rn = 1 +) AS ods_rte ON ods_rte.route_no = flt.route_no +LEFT JOIN dds.dim_routes AS rte + ON rte.route_bk = flt.route_no + AND flt.scheduled_departure::DATE >= rte.valid_from + AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to) +... +LEFT JOIN dds.dim_airports AS dep + ON dep.airport_bk = ods_rte.departure_airport -- через ods.routes (эталон) +LEFT JOIN dds.dim_airports AS arr + ON arr.airport_bk = ods_rte.arrival_airport -- через ods.routes (эталон) +LEFT JOIN dds.dim_airplanes AS ap + ON ap.airplane_bk = rte.airplane_code -- через dim_routes (point-in-time, как прежде) +``` + +**Почему аэропорты через ods.routes, а airplane через dim_routes:** + +- **Аэропорты**: `departure_airport` и `arrival_airport` одинаковы во всех версиях + одного `route_no` (маршрут SVO→LED всегда SVO→LED). Безопасно брать из ODS. + Это даёт эталонным витринам (`sales_report`, `airport_traffic`) корректные `airport_sk` + даже без студенческого `dim_routes`. + +- **Самолёт**: `airplane_code` теоретически может отличаться между версиями маршрута. + Текущий контракт DDS (`bookings_dds_design.md:499`) фиксирует, что `airplane_sk` + приходит из **той же point-in-time версии** маршрута, что и `route_sk`. + Менять эту семантику — изменение модели данных, а не стабилизация. + На main `airplane_sk` будет NULL (dim_routes — заглушка). Это допустимо: + ни `sales_report`, ни `airport_traffic` не используют `airplane_sk`. + +### 2. `sql/dds/fact_flight_sales_dq.sql` — ослабить проверки студенческих SK (только main) + +**Ветка:** только main (Этап 4). На solution-ветке полные проверки сохраняются. + +Изменения: + +- **Строки 65-75** (`passenger_sk IS NULL`): заменить `RAISE EXCEPTION` на `RAISE NOTICE`. + На main `dim_passengers` — заглушка, 100% строк будут с `passenger_sk IS NULL`. + Любой порог (даже >1%) даст exception. **Только логирование, без блокировки.** + +- **Строки 89-108** (route-related FK): разделить на два блока: + + **Блок A — эталонные FK (проверка с порогом 1%, как calendar_sk):** + ```sql + -- departure_airport_sk и arrival_airport_sk заполняются через ods.routes (эталон). + -- NULL здесь — аномалия данных (пропущен маршрут в ODS), а не отсутствие студенческого кода. + -- Порог 1% — защита от единичных аномалий источника (аналогично calendar_sk). + SELECT COUNT(*) INTO v_null_airport + FROM dds.fact_flight_sales + WHERE departure_airport_sk IS NULL OR arrival_airport_sk IS NULL; + + IF v_null_airport > 0 THEN + IF v_null_airport * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN + RAISE EXCEPTION 'DQ FAILED: NULL airport_sk: % (>1%%)', v_null_airport; + ELSE + RAISE NOTICE 'DQ WARNING: NULL airport_sk: % (<=1%%, допустимо)', v_null_airport; + END IF; + END IF; + ``` + + **Блок B — студенческие FK (только логирование, RAISE NOTICE):** + ```sql + -- route_sk и airplane_sk будут NULL, пока студент не реализует dim_routes. + -- Не блокируем pipeline. + SELECT COUNT(*) INTO v_null_student + FROM dds.fact_flight_sales + WHERE route_sk IS NULL OR airplane_sk IS NULL; + + IF v_null_student > 0 THEN + RAISE NOTICE 'DQ INFO: студенческие SK (route/airplane) NULL: %. ' + 'После реализации dim_routes: TRUNCATE fact → перезагрузка.', + v_null_student; + END IF; + ``` + +Итоговое правило на main: +- `departure_airport_sk`, `arrival_airport_sk` — **проверка с порогом 1%** (эталон, через ods.routes; >1% → EXCEPTION, <=1% → NOTICE) +- `passenger_sk`, `route_sk`, `airplane_sk` — **только RAISE NOTICE** (студенческие заглушки, 100% NULL допустимо) + +```sql +-- Учебный комментарий: route_sk, airplane_sk и passenger_sk будут NULL, +-- пока вы не реализуете соответствующие DDS-измерения. +-- После реализации: TRUNCATE dds.fact_flight_sales → перезагрузка → все SK заполнены. +-- Полную версию DQ (с блокировкой) см. в ветке solution. +``` + +### 3. ODS Routes DQ: убрать RI-проверку airplane_code (только main) + +**Ветка:** только main (Этап 4). На solution-ветке полные проверки сохраняются. + +> **Почему только ODS, не STG?** Весь STG — эталон, `stg.airplanes` всегда заполнен, +> `stg/routes_dq.sql` RI-проверка к `stg.airplanes` проходит штатно. Менять STG не нужно. +> На уровне ODS `ods.airplanes` — студенческая заглушка (пустая), поэтому +> `ods/routes_dq.sql` RI-проверка к `ods.airplanes` упадёт. + +**Файл:** +- `sql/ods/routes_dq.sql` — удалить блок строк 143-156 (RI к `ods.airplanes`) + +Добавить комментарий: +```sql +-- Учебный комментарий: проверка RI airplane_code → ods.airplanes не выполняется, +-- т.к. таблица ods.airplanes реализуется студентом. После реализации — раскомментируйте +-- (см. ветку solution для полной версии). +``` + +### 4. ODS DAG-зависимость: убрать airplanes → routes (только main) + +**Ветка:** только main (Этап 4) + +> **Почему только ODS, не STG?** STG DAG не меняется: `stg.airplanes` — эталон, +> барьер `check_airplanes_dq >> load_routes_to_stg` работает штатно. +> На уровне ODS `dq_ods_airplanes` — заглушка (`SELECT 1;`), формально проходит, +> но зависимость вводит в заблуждение. Убираем для ясности. + +`airflow/dags/bookings_to_gp_ods.py` (строка 266): +```python +# Было: +[dq_ods_airports, dq_ods_airplanes] >> load_ods_routes +# Стало: +dq_ods_airports >> load_ods_routes +``` + +### 5. Заглушки для студенческих файлов (только main) + +**Ветка:** только main (Этап 4) + +#### STG-слой: весь код остаётся (эталон), заглушки не нужны + +Все 9 STG-таблиц загружаются эталонным кодом. Batch resolver работает без изменений. + +#### ODS-слой: заглушки для airplanes + seats + +| Файл | main (студент) | solution | +|------|----------------|----------| +| `sql/ods/airplanes_ddl.sql` | Полный DDL (таблица нужна) | Тот же | +| `sql/ods/airplanes_load.sql` | Заглушка: `SELECT 1; -- TODO` | Полная реализация | +| `sql/ods/airplanes_dq.sql` | Заглушка: `SELECT 1;` | Полная реализация | +| `sql/ods/seats_ddl.sql` | Полный DDL | Тот же | +| `sql/ods/seats_load.sql` | Заглушка | Полная реализация | +| `sql/ods/seats_dq.sql` | Заглушка | Полная реализация | + +#### DDS-слой: заглушки для 3 измерений + +| Файл | main (студент) | solution | +|------|----------------|----------| +| `sql/dds/dim_routes_ddl.sql` | Полный DDL (нужен для LEFT JOIN) | Тот же | +| `sql/dds/dim_routes_load.sql` | Заглушка: `SELECT 1; -- TODO: реализуйте SCD2` | Полная реализация | +| `sql/dds/dim_routes_dq.sql` | Заглушка: `SELECT 1;` | Полная реализация | +| `sql/dds/dim_passengers_ddl.sql` | Полный DDL | Тот же | +| `sql/dds/dim_passengers_load.sql` | Заглушка | Полная реализация | +| `sql/dds/dim_passengers_dq.sql` | Заглушка | Полная реализация | +| `sql/dds/dim_airplanes_ddl.sql` | Полный DDL | Тот же | +| `sql/dds/dim_airplanes_load.sql` | Заглушка | Полная реализация | +| `sql/dds/dim_airplanes_dq.sql` | Заглушка | Полная реализация | + +#### DM-слой: заглушки для 4 витрин + +Аналогично: DDL остаётся, load/dq заменяются заглушками. +Файлы: `airport_traffic`, `route_performance`, `monthly_overview`, `passenger_loyalty`. + +--- + +## Изменения (документация) + +### 6. `docs/assignment/analyst_spec.md` + +- **Удалить** разделы 1.1-1.3 (все STG-задания) — весь STG теперь эталон +- **Удалить** разделы 2.3 (`ods.routes`) — routes теперь эталон +- **Обновить** рекомендуемый порядок: начинается с ODS (airplanes, seats) +- **Добавить** мотивационную секцию «Почему STG уже реализован»: + - Эталонный пайплайн требует согласованного batch по всем snapshot-таблицам + - Без данных в STG невозможна загрузка ODS и далее по цепочке + - Студент изучает эталонный STG-код как образец +- **Добавить** секцию «Пересчёт факта после реализации измерений»: + - Инструкция: TRUNCATE fact + re-run после реализации всех DDS-измерений + - Педагогическая ценность: late-arriving dimensions, dependency management + +### 7. `docs/design/assignment_design.md` + +- **Обновить** таблицу «Эталонные таблицы»: добавить весь STG + ods.routes +- **Обновить** таблицу «Задание студенту»: убрать STG целиком, ODS оставить airplanes+seats +- **Обновить** рекомендуемый порядок: начинается с ODS +- **Обновить** структуру валидационного DAG (раздел 4): убрать `validate_stg` секцию + +### 8. `docs/design/bookings_dds_design.md` + +- **Строки 463, 468** (SQL-скелет факта): обновить JOIN-блок — + `dep.airport_bk` и `arr.airport_bk` теперь через `ods_rte`, а не через `rte`; + `ap.airplane_bk` остаётся через `rte` (point-in-time) +- **Строка 499-501** (текстовое пояснение): обновить — + `departure_airport_sk` и `arrival_airport_sk` берутся из `ods.routes` (эталон); + `airplane_sk` остаётся point-in-time через `dim_routes` +- **Строка 558** (passenger_sk = 0): убрать утверждение о нулевой толерантности — + на main `passenger_sk` будет 100% NULL (dim_passengers — заглушка) +- **Строка 559** (DQ текстовое описание): обновить — на main student SK + (`passenger_sk`, `route_sk`, `airplane_sk`) не блокируют pipeline; + `airport_sk` проверяются с порогом 1% +- **Строка 741** (DQ SQL-пример): обновить встроенный SQL — разделить route-related + блок на эталонный (airport_sk, порог 1%) и студенческий (route_sk, airplane_sk, NOTICE) + +### 9. `docs/design/db_schema.md` + +- Пометить весь STG и `ods.routes` как эталонные (не студенческие) +- Строка 3: «Все слои реализованы» — уточнить, что на main ODS airplanes/seats, + DDS student dims и DM student vitrines — заглушки +- Строка 253: DQ-контракт «SQL-скрипты с RAISE EXCEPTION» — добавить оговорку, + что на main студенческие DQ-скрипты являются заглушками + +### 10. Тесты + +- `tests/test_dags_smoke.py` строки 242-243: + На main убрать assert барьера `dq_ods_airplanes → dq_ods_routes` (ODS routes + не зависит от ODS airplanes на main). STG-барьеры (строки 123-124) **не трогаем**: + STG целиком эталонный, барьер `check_airplanes_dq → check_routes_dq` работает. +- `tests/test_ods_sql_contract.py` строка 6: + На main изменить `SNAPSHOT_ENTITIES` — исключить студенческие ODS: + ```python + # Было: + SNAPSHOT_ENTITIES = ("airports", "airplanes", "routes", "seats") + # Стало (main): + SNAPSHOT_ENTITIES = ("airports", "routes") + ``` + Тесты `test_snapshot_load_scripts_use_truncate` (строка 13) и + `test_snapshot_dq_checks_extra_keys` (строка 22) проверяют паттерны TRUNCATE+INSERT + и `v_extra_keys_count` в SQL-файлах. Заглушки `SELECT 1;` не содержат этих паттернов → + тесты упадут для airplanes/seats. Исключение из `SNAPSHOT_ENTITIES` решает проблему. + Batch resolver тест (строка 30) проверяет DAG-код (не SQL-файлы) — без изменений. + +### 11. Прочие документы + +- `docs/design/PRD.md` — обновить чеклист (раздел 10) +- `docs/design/bookings_ods_design.md` — комплексное обновление: + - Строка 29, 515, 534: пометить `airplanes` и `seats` как студенческие + (на main — заглушки; STG-уровень — эталон, ODS load/dq — студент) + - Строки 521, 523: скорректировать общий DQ-контракт — на main `airplanes_dq.sql` + и `seats_dq.sql` являются заглушками, а не полноценными RAISE EXCEPTION скриптами + - Строка 620: обновить DAG-граф — routes больше не зависит от airplanes на ODS + - Строка 634: убрать зависимость routes от airplanes в описании FK +- `docs/bookings_to_gp_ods.md` — обновить описание +- `docs/bookings_to_gp_dds.md`: + - Строки 111-113: убрать гарантию отсутствия NULL SK в штатном режиме — + на main student SK (`passenger_sk`, `route_sk`, `airplane_sk`) будут NULL + - Строка 116: обновить DQ-семантику — student SK не блокируют (NOTICE); + airport_sk — порог 1% + - Строка 126: обновить описание lookup (airports через ods.routes) +- `docs/reference/qa-plan.md`: + - Строка 80: «ODS: все 9 таблиц не пустые» — на main `ods.airplanes` и `ods.seats` + будут пустыми by design (студенческие заглушки). Уточнить формулировку. +- `docs/e2e-etl-test-protocol.md` — обновить контекст + +--- + +## Порядок выполнения + +### Сейчас (на chore/bookings-etl) + +1. **Код**: изменить `fact_flight_sales_load.sql` (lookup через ods.routes) — п.1 +2. **Документация**: обновить `analyst_spec.md` — п.6 +3. **Документация**: обновить `assignment_design.md` — п.7 +4. **Документация**: обновить `db_schema.md` — п.9 +5. **Тестирование**: `make test` + ручная проверка SQL +6. **Коммит** + +### Этап 3 (валидационный DAG) + +- Проектировать с учётом DDL-заглушек +- `validate_stg` секцию **не делать** (STG — эталон) +- `check_dim_routes_scd2` — активный тест (мутация ODS → проверка → откат) + +### Этап 4 (подготовка main + solution) + +- Fact DQ: ослабить проверки student SK (main only) — п.2 +- Routes DQ: убрать airplane RI (main only) — п.3 +- DAG-зависимости: убрать airplanes → routes (main only) — п.4 +- Заглушки: ODS (airplanes, seats) + DDS (3 dims) + DM (4 vitrines) — п.5 +- Тесты: обновить smoke-тесты (main only) — п.10 +- Документация: bookings_dds_design, PRD, прочее — п.8, п.11 + +--- + +## Верификация + +### После п.1 (fact_flight_sales fix) + +```bash +make test # smoke-тесты DAG +``` + +Ручная проверка (при поднятом стенде): + +> **Важно:** UPDATE факта не перезаписывает dimension SK (by design, строка 4-5). +> Уже загруженные строки сохранят старые значения `airport_sk` (через dim_routes). +> Для полной проверки новой логики нужен **TRUNCATE + перезагрузка**: + +```sql +TRUNCATE dds.fact_flight_sales; +-- Перезапустить DDS DAG (или только task load_dds_fact_flight_sales) + +-- Проверка: airport_sk заполнены (через ods.routes), route_sk тоже (dim_routes заполнен на этой ветке) +SELECT + COUNT(*) AS total, + COUNT(departure_airport_sk) AS has_dep_sk, + COUNT(arrival_airport_sk) AS has_arr_sk, + COUNT(route_sk) AS has_route_sk +FROM dds.fact_flight_sales; +``` + +### После Этапа 4 (main branch) + +1. `make up` → `make ddl-gp` → запустить STG+ODS+DDS DAG-и +2. Проверить: `sales_report` содержит данные с корректными аэропортами +3. Проверить: студенческие ODS/DDS-таблицы пусты (заглушки) +4. Fact DQ проходит (student SK = NULL, но DQ не блокирует) +5. Реализовать один студенческий dim → TRUNCATE fact → re-run → проверить SK + +--- + +## Ключевые файлы + +| Файл | Роль | Ветка | +|------|------|-------| +| `sql/dds/fact_flight_sales_load.sql` | Главное изменение: airport lookup через ods.routes | обе | +| `sql/dds/fact_flight_sales_dq.sql` | Student SK: EXCEPTION → NOTICE | main only | +| `sql/ods/routes_dq.sql` | Убрать RI airplane_code → ods.airplanes | main only | +| `airflow/dags/bookings_to_gp_ods.py` | DAG: убрать airplanes → routes | main only | +| `tests/test_ods_sql_contract.py` | SNAPSHOT_ENTITIES: убрать airplanes, seats | main only | +| `tests/test_dags_smoke.py` | ODS-барьер airplanes → routes | main only | +| `docs/assignment/analyst_spec.md` | Убрать STG-задания, убрать ods.routes | обе | +| `docs/design/assignment_design.md` | Обновить таблицы эталон/задание | обе | +| `docs/design/bookings_dds_design.md` | Обновить описание fact lookup | обе | +| `docs/design/bookings_ods_design.md` | Убрать зависимость routes от airplanes | main only | +| `docs/design/db_schema.md` | Пометить STG + ods.routes как эталон | обе | + +> **STG не трогаем**: `sql/stg/routes_dq.sql`, `bookings_to_gp_stage.py`, +> STG smoke-тесты — без изменений. Весь STG эталонный, зависимости работают штатно. diff --git a/docs/archive/2026-03-12_validation-dag.md b/docs/archive/2026-03-12_validation-dag.md new file mode 100644 index 0000000..f1e8f5f --- /dev/null +++ b/docs/archive/2026-03-12_validation-dag.md @@ -0,0 +1,999 @@ +# План: Валидационный DAG `bookings_validate` + +## Контекст и мотивация + +### Проблема + +Студент реализует 9 объектов (2 ODS, 3 DDS-измерения, 4 DM-витрины) и **не имеет +автоматической обратной связи** — правильно ли работает его код. Сейчас единственный +способ проверки — ручные SQL-запросы и визуальный контроль данных в таблицах. + +Это приводит к типичным ошибкам, которые студент не замечает: + +| Ошибка | Где проявляется | Когда обнаруживается | +|--------|-----------------|----------------------| +| NULL в PK | ODS/DDS | Только при построении витрины (неожиданные NULL в агрегатах) | +| Дубли по BK | DDS dim_passengers | Факт раздувается, витрины считают неверно | +| SCD2 не закрывает версии | DDS dim_routes | `valid_to` всегда NULL → point-in-time JOIN перестаёт работать | +| «Дыры» в SCD2-интервалах | DDS dim_routes | `sales_report` теряет строки за «пустые» даты | +| TRUNCATE+INSERT не вытащил все строки | ODS airplanes/seats | DDS-измерение неполное → NULL в факте | +| Витрина пуста при непустом источнике | DM | Видно сразу, но причина непонятна | + +Без валидационного DAG студент узнаёт о проблеме **через 2-3 слоя** — на этапе DM-витрин, +где отладка многократно сложнее. + +### Решение + +**Отдельный DAG `bookings_validate`** — набор проверок, сгруппированных по слоям. +Студент запускает его вручную (trigger в Airflow UI) после реализации заданий. +Каждый таск — одна проверка, с дружелюбным сообщением об ошибке и подсказкой, +что делать дальше. + +### Педагогическая ценность + +1. **Практика чтения логов Airflow** — студент учится находить ошибки в логах тасков +2. **Инкрементальная обратная связь** — можно запускать после каждого шага, не дожидаясь + реализации всех заданий +3. **Паттерн DQ в пайплайне** — студент видит, как устроены production-проверки качества данных +4. **Активный тест SCD2** — единственный способ убедиться, что SCD2-логика действительно + работает (справочник `routes` в демо-базе статичен) + +### Дизайн-решения + +| Решение | Обоснование | +|---------|-------------| +| Отдельный DAG (не часть основного пайплайна) | Запускается по желанию, не блокирует основную загрузку | +| `schedule=None` (ручной триггер) | Студент запускает, когда готов проверить свой код | +| `PostgresOperator` + SQL-скрипты | Единый паттерн с основными DAG-ами | +| SQL в `sql/validate/` | Отдельный каталог — валидация не смешивается с DQ пайплайна | +| `DO $$ ... RAISE EXCEPTION ... $$` | Тот же паттерн, что в эталонных DQ-скриптах | +| TaskGroup по слоям | Студент видит, на каком слое проблема | +| Проверки «существует + не пуста» для DM | Минимально необходимо; бизнес-инварианты студент добавит сам в DQ витрин | + +--- + +## Структура DAG + +``` +bookings_validate +├── validate_ods (TaskGroup) +│ ├── check_ods_airplanes_rowcount +│ ├── check_ods_seats_rowcount +│ ├── check_ods_no_dup_bk +│ └── check_ods_no_null_pks +├── validate_dds (TaskGroup) +│ ├── check_dim_airplanes_exists +│ ├── check_dim_passengers_exists +│ ├── check_dim_passengers_no_dup_bk +│ ├── check_dim_routes_exists +│ ├── check_dim_routes_scd2 ← активный тест! +│ └── check_dim_routes_no_gaps +└── validate_dm (TaskGroup) + ├── check_airport_traffic_exists + ├── check_route_performance_exists + ├── check_monthly_overview_exists + └── check_passenger_loyalty_exists +``` + +### Зависимости между группами + +Нет жёстких зависимостей. Все три группы запускаются параллельно — студент может +реализовать только ODS и увидеть зелёные чеки для `validate_ods`, пока DDS/DM ещё +красные. Это даёт инкрементальную обратную связь. + +Внутри каждой группы таски также параллельны (независимые проверки). + +--- + +## Детализация проверок + +### ODS: `check_ods_airplanes_rowcount` + +**Файл:** `sql/validate/ods_airplanes_rowcount.sql` + +**Логика:** ODS загружает один конкретный snapshot-батч из STG (по `_load_id`). +STG — append-only: содержит историю всех загрузок. Поэтому сравнивать ODS +со всей STG некорректно — нужно проверять точное множество BK для того батча, +который ODS фактически загрузил. + +Определяем батч: `_load_id` из `ods.airplanes` (единственный, т.к. TRUNCATE+INSERT). +Затем проверяем, что множество BK в ODS = множество BK в STG для этого батча. + +```sql +DO $$ +DECLARE + v_batch_count BIGINT; + v_batch TEXT; + v_ods_count BIGINT; + v_missing_in_ods BIGINT; + v_extra_in_ods BIGINT; +BEGIN + -- ODS пуста? + SELECT COUNT(*) INTO v_ods_count FROM ods.airplanes; + IF v_ods_count = 0 THEN + RAISE EXCEPTION 'FAILED: ods.airplanes пуста. Реализуйте загрузку: sql/ods/airplanes_load.sql'; + END IF; + + -- Инвариант: ODS после TRUNCATE+INSERT содержит ровно один _load_id + SELECT COUNT(DISTINCT _load_id) INTO v_batch_count FROM ods.airplanes; + IF v_batch_count <> 1 THEN + RAISE EXCEPTION 'FAILED: ods.airplanes содержит % разных _load_id (ожидается 1 после TRUNCATE+INSERT). Проверьте, что load начинается с TRUNCATE.', v_batch_count; + END IF; + + -- Определяем батч, из которого загружена ODS + SELECT DISTINCT _load_id INTO v_batch FROM ods.airplanes; + + -- BK есть в STG-батче, но нет в ODS (потеряны при загрузке) + SELECT COUNT(*) INTO v_missing_in_ods + FROM ( + SELECT DISTINCT airplane_code FROM stg.airplanes WHERE _load_id = v_batch + ) AS stg_bk + WHERE NOT EXISTS ( + SELECT 1 FROM ods.airplanes AS o WHERE o.airplane_code = stg_bk.airplane_code + ); + + IF v_missing_in_ods > 0 THEN + RAISE EXCEPTION 'FAILED: % самолётов из STG-батча (%) отсутствуют в ods.airplanes. Проверьте логику TRUNCATE+INSERT.', v_missing_in_ods, v_batch; + END IF; + + -- BK есть в ODS, но нет в STG-батче (откуда взялись?) + SELECT COUNT(*) INTO v_extra_in_ods + FROM ( + SELECT DISTINCT airplane_code FROM ods.airplanes + ) AS ods_bk + WHERE NOT EXISTS ( + SELECT 1 FROM stg.airplanes AS s WHERE s._load_id = v_batch AND s.airplane_code = ods_bk.airplane_code + ); + + IF v_extra_in_ods > 0 THEN + RAISE EXCEPTION 'FAILED: % самолётов в ods.airplanes отсутствуют в STG-батче (%). Возможно, TRUNCATE не выполнился перед INSERT.', v_extra_in_ods, v_batch; + END IF; + + RAISE NOTICE 'PASSED: ods.airplanes содержит % самолётов, множество BK = STG-батч %', v_ods_count, v_batch; +END $$; +``` + +### ODS: `check_ods_seats_rowcount` + +**Файл:** `sql/validate/ods_seats_rowcount.sql` + +Аналогичная проверка по составному ключу `(airplane_code, seat_no)` — +точное множество BK из STG-батча (по `_load_id` из `ods.seats`). + +### ODS: `check_ods_no_dup_bk` + +**Файл:** `sql/validate/ods_no_dup_bk.sql` + +**Логика:** Проверить, что в ODS нет дублей по бизнес-ключу. Типичная ошибка +студента — INSERT без предшествующего TRUNCATE, или TRUNCATE забыт при повторном +запуске. Дубли по BK в ODS каскадно ломают DDS (лишние строки в измерениях). + +```sql +DO $$ +DECLARE + v_dup_airplanes BIGINT; + v_dup_seats BIGINT; +BEGIN + -- airplanes: BK = airplane_code + SELECT COUNT(*) INTO v_dup_airplanes + FROM ( + SELECT airplane_code FROM ods.airplanes + GROUP BY airplane_code HAVING COUNT(*) > 1 + ) AS d; + + IF v_dup_airplanes > 0 THEN + RAISE EXCEPTION 'FAILED: ods.airplanes содержит % дублирующихся airplane_code. Проверьте, что load начинается с TRUNCATE.', v_dup_airplanes; + END IF; + + -- seats: BK = (airplane_code, seat_no) + SELECT COUNT(*) INTO v_dup_seats + FROM ( + SELECT airplane_code, seat_no FROM ods.seats + GROUP BY airplane_code, seat_no HAVING COUNT(*) > 1 + ) AS d; + + IF v_dup_seats > 0 THEN + RAISE EXCEPTION 'FAILED: ods.seats содержит % дублирующихся (airplane_code, seat_no). Проверьте, что load начинается с TRUNCATE.', v_dup_seats; + END IF; + + RAISE NOTICE 'PASSED: Нет дублей по BK в ods.airplanes и ods.seats'; +END $$; +``` + +### ODS: `check_ods_no_null_pks` + +**Файл:** `sql/validate/ods_no_null_pks.sql` + +**Логика:** Проверить, что PK-поля не содержат NULL в обеих студенческих ODS-таблицах. + +```sql +DO $$ +DECLARE + v_null_airplanes BIGINT; + v_null_seats BIGINT; +BEGIN + -- airplanes: PK = airplane_code + SELECT COUNT(*) INTO v_null_airplanes + FROM ods.airplanes WHERE airplane_code IS NULL; + + IF v_null_airplanes > 0 THEN + RAISE EXCEPTION 'FAILED: ods.airplanes содержит % строк с NULL в airplane_code.', v_null_airplanes; + END IF; + + -- seats: PK = (airplane_code, seat_no) + SELECT COUNT(*) INTO v_null_seats + FROM ods.seats WHERE airplane_code IS NULL OR seat_no IS NULL; + + IF v_null_seats > 0 THEN + RAISE EXCEPTION 'FAILED: ods.seats содержит % строк с NULL в PK (airplane_code, seat_no).', v_null_seats; + END IF; + + RAISE NOTICE 'PASSED: PK не содержат NULL в ods.airplanes и ods.seats'; +END $$; +``` + +--- + +### DDS: `check_dim_airplanes_exists` + +**Файл:** `sql/validate/dim_airplanes_exists.sql` + +**Логика:** +1. Таблица не пуста +2. Нет дублей по `airplane_bk` +3. Покрытие ODS: все `airplane_code` из `ods.airplanes` есть в `dds.dim_airplanes` + +```sql +DO $$ +DECLARE + v_count BIGINT; + v_dup BIGINT; + v_missing BIGINT; +BEGIN + SELECT COUNT(*) INTO v_count FROM dds.dim_airplanes; + IF v_count = 0 THEN + RAISE EXCEPTION 'FAILED: dds.dim_airplanes пуста. Реализуйте загрузку: sql/dds/dim_airplanes_load.sql'; + END IF; + + SELECT COUNT(*) - COUNT(DISTINCT airplane_bk) INTO v_dup FROM dds.dim_airplanes; + IF v_dup > 0 THEN + RAISE EXCEPTION 'FAILED: dds.dim_airplanes содержит % дублей по airplane_bk. Проверьте SCD1-логику (UPSERT).', v_dup; + END IF; + + SELECT COUNT(*) INTO v_missing + FROM (SELECT DISTINCT airplane_code FROM ods.airplanes) AS s + WHERE NOT EXISTS ( + SELECT 1 FROM dds.dim_airplanes AS d WHERE d.airplane_bk = s.airplane_code + ); + IF v_missing > 0 THEN + RAISE EXCEPTION 'FAILED: % самолётов из ods.airplanes отсутствуют в dds.dim_airplanes.', v_missing; + END IF; + + RAISE NOTICE 'PASSED: dds.dim_airplanes содержит % строк, покрывает все BK из ODS', v_count; +END $$; +``` + +### DDS: `check_dim_passengers_exists` + +**Файл:** `sql/validate/dim_passengers_exists.sql` + +Аналогичная структура: не пуста + нет дублей по `passenger_id` + покрытие +`ods.tickets` (все уникальные `passenger_id` из тикетов есть в измерении). + +### DDS: `check_dim_passengers_no_dup_bk` + +**Файл:** `sql/validate/dim_passengers_no_dup_bk.sql` + +Отдельная проверка дублей `passenger_id` — типичная ошибка студентов при SCD1: +INSERT без проверки EXISTS создаёт дубли. Выделена в отдельный таск для ясности +сообщения об ошибке. + +**Примечание:** Эту проверку можно объединить с `check_dim_passengers_exists`. +Отдельный таск оправдан, если хотим дать студенту более точную диагностику. +Решение — на усмотрение реализатора. + +### DDS: `check_dim_routes_exists` + +**Файл:** `sql/validate/dim_routes_exists.sql` + +Таблица не пуста + покрытие ODS (`route_no`) + **не более одной текущей версии +на `route_bk`** (инвариант SCD2: `COUNT(*) WHERE valid_to IS NULL` <= 1 для каждого BK). + +Типичная ошибка студента — INSERT без проверки `NOT EXISTS ... AND hashdiff = ...`, +что создаёт дубли текущей версии. Активный SCD2-тест проверяет только один маршрут, +эта проверка ловит проблему глобально. + +```sql +DO $$ +DECLARE + v_count BIGINT; + v_missing BIGINT; + v_multi_current BIGINT; +BEGIN + SELECT COUNT(*) INTO v_count FROM dds.dim_routes; + IF v_count = 0 THEN + RAISE EXCEPTION 'FAILED: dds.dim_routes пуста. Реализуйте загрузку: sql/dds/dim_routes_load.sql'; + END IF; + + -- Покрытие ODS + SELECT COUNT(*) INTO v_missing + FROM (SELECT DISTINCT route_no FROM ods.routes) AS s + WHERE NOT EXISTS ( + SELECT 1 FROM dds.dim_routes AS d WHERE d.route_bk = s.route_no + ); + IF v_missing > 0 THEN + RAISE EXCEPTION 'FAILED: % маршрутов из ods.routes отсутствуют в dds.dim_routes.', v_missing; + END IF; + + -- Инвариант SCD2: не более одной текущей версии на route_bk + SELECT COUNT(*) INTO v_multi_current + FROM ( + SELECT route_bk FROM dds.dim_routes + WHERE valid_to IS NULL + GROUP BY route_bk HAVING COUNT(*) > 1 + ) AS d; + + IF v_multi_current > 0 THEN + RAISE EXCEPTION E'FAILED: % маршрутов имеют более одной текущей версии (valid_to IS NULL).\n' + 'Подсказка: при INSERT новой версии проверяйте NOT EXISTS ... AND hashdiff = ...\n' + 'чтобы не создавать дубликат, если hashdiff не изменился.', v_multi_current; + END IF; + + RAISE NOTICE 'PASSED: dds.dim_routes содержит % строк, покрывает ODS, по одной текущей версии на маршрут', v_count; +END $$; +``` + +### DDS: `check_dim_routes_scd2` (АКТИВНЫЙ ТЕСТ) + +**Файл:** `sql/validate/dim_routes_scd2.sql` + +Это ключевая проверка плана. Справочник `bookings.routes` в демо-базе **статичен** — +маршруты не меняются между запусками генератора. При обычном прогоне пайплайна +студент **никогда не увидит**, как SCD2 закрывает старую версию и создаёт новую. + +**Алгоритм активного теста:** + +``` +1. Бэкап: CREATE TABLE _validate_bk_ods_routes AS SELECT * FROM ods.routes; + CREATE TABLE _validate_bk_dim_routes AS SELECT * FROM dds.dim_routes; + (обычные таблицы — TEMP не сохраняются между тасками Airflow) + +2. Мутация: Выбрать один маршрут из ods.routes (WHERE departure_time IS NOT NULL LIMIT 1). + Сохранить его route_no в служебную таблицу _validate_scd2_target. + Обновить в ods.routes его departure_time на +1 час. + +3. Запуск студенческого кода: PostgresOperator(sql="dds/dim_routes_load.sql") + +4. Проверки (все 5): + a) Ровно 2 версии тестового маршрута (было 1, стало 2) + b) Старая версия закрыта: valid_to IS NOT NULL + c) Ровно 1 открытая версия: valid_to IS NULL + d) hashdiff старой ≠ hashdiff новой (мутация отразилась в хеше) + e) Нет «дыры»: старая.valid_to = новая.valid_from + +5. Откат (с проверкой существования бэкапов через to_regclass): + IF _validate_bk_ods_routes exists: TRUNCATE ods.routes + INSERT FROM backup; + IF _validate_bk_dim_routes exists: TRUNCATE dds.dim_routes + INSERT FROM backup; + DROP TABLE IF EXISTS _validate_bk_*, _validate_scd2_target; +``` + +**Реализация:** Этот таск **не может быть простым PostgresOperator** с одним SQL, +потому что шаг 3 — это вызов студенческого SQL-скрипта *внутри* теста. Варианты: + +**Выбранный подход: цепочка тасков.** +- Jinja-шаблоны работают из коробки +- Каждый шаг прозрачен в Airflow UI +- `trigger_rule="all_done"` на restore гарантирует откат +- Студент видит в UI, на каком именно шаге проблема + +### DDS: `check_dim_routes_no_gaps` + +**Файл:** `sql/validate/dim_routes_no_gaps.sql` + +**Логика:** Для каждого `route_bk` с несколькими версиями проверить, что +`valid_to` предыдущей версии = `valid_from` следующей (полуоткрытый интервал +`[valid_from, valid_to)` без «дыр»). + +**Важно:** SCD2-загрузка поддерживает «исчезнувшие» маршруты (`dim_routes_load.sql`, +Statement 1.1): если маршрут пропал из ODS, его текущая версия закрывается +(`valid_to = CURRENT_DATE`), но новая **не вставляется**. Это корректное поведение — +у такого маршрута `valid_to IS NOT NULL` и `next_valid_from IS NULL`. Проверка +должна считать «дырой» только случаи, когда следующая версия **существует**, +но `valid_to ≠ next_valid_from`. + +```sql +DO $$ +DECLARE + v_gaps BIGINT; +BEGIN + SELECT COUNT(*) INTO v_gaps + FROM ( + SELECT route_bk, valid_to, + LEAD(valid_from) OVER (PARTITION BY route_bk ORDER BY valid_from) AS next_valid_from + FROM dds.dim_routes + ) AS t + WHERE valid_to IS NOT NULL + AND next_valid_from IS NOT NULL -- следующая версия существует (не «исчезнувший» маршрут) + AND valid_to <> next_valid_from; + + IF v_gaps > 0 THEN + RAISE EXCEPTION 'FAILED: В dds.dim_routes найдено % «дыр» между версиями SCD2. valid_to старой версии должен совпадать с valid_from новой.', v_gaps; + END IF; + + RAISE NOTICE 'PASSED: Нет «дыр» в SCD2-версиях dim_routes'; +END $$; +``` + +--- + +### DM: `check_{vitrine}_exists` (4 таска) + +**Файлы:** +- `sql/validate/airport_traffic_exists.sql` +- `sql/validate/route_performance_exists.sql` +- `sql/validate/monthly_overview_exists.sql` +- `sql/validate/passenger_loyalty_exists.sql` + +**Логика:** Минимальная проверка — таблица не пуста + нет NULL в ключевых полях. + +Пример для `airport_traffic`: + +```sql +DO $$ +DECLARE + v_count BIGINT; + v_null_pk BIGINT; +BEGIN + SELECT COUNT(*) INTO v_count FROM dm.airport_traffic; + IF v_count = 0 THEN + RAISE EXCEPTION 'FAILED: dm.airport_traffic пуста. Реализуйте загрузку: sql/dm/airport_traffic_load.sql'; + END IF; + + SELECT COUNT(*) INTO v_null_pk + FROM dm.airport_traffic + WHERE traffic_date IS NULL OR airport_sk IS NULL; + + IF v_null_pk > 0 THEN + RAISE EXCEPTION 'FAILED: dm.airport_traffic содержит % строк с NULL в ключе (traffic_date, airport_sk).', v_null_pk; + END IF; + + RAISE NOTICE 'PASSED: dm.airport_traffic содержит % строк', v_count; +END $$; +``` + +Для `route_performance` ключ — `route_bk`, для `monthly_overview` — `(year_actual, month_actual, airplane_sk)`, +для `passenger_loyalty` — `passenger_sk`. + +--- + +## Файлы для создания + +### SQL-скрипты (`sql/validate/`) + +| # | Файл | Описание | +|---|------|----------| +| 1 | `ods_airplanes_rowcount.sql` | BK coverage: ODS vs STG-батч | +| 2 | `ods_seats_rowcount.sql` | BK coverage: ODS vs STG-батч | +| 3 | `ods_no_dup_bk.sql` | Нет дублей по BK в ODS (airplanes + seats) | +| 4 | `ods_no_null_pks.sql` | NULL в PK обеих ODS-таблиц | +| 5 | `dim_airplanes_exists.sql` | Не пуста + нет дублей BK + покрытие ODS | +| 6 | `dim_passengers_exists.sql` | Не пуста + нет дублей BK + покрытие ODS | +| 7 | `dim_passengers_no_dup_bk.sql` | Дубли `passenger_id` (отдельная диагностика) | +| 8 | `dim_routes_exists.sql` | Не пуста + покрытие ODS + не более 1 текущей версии на BK | +| 9 | `dim_routes_scd2_backup.sql` | Бэкап ods.routes + dds.dim_routes | +| 10 | `dim_routes_scd2_mutate.sql` | Мутация тестового маршрута + сохранение route_no | +| 11 | `dim_routes_scd2_check.sql` | 5 проверок SCD2 после загрузки | +| 12 | `dim_routes_scd2_restore.sql` | Безопасный откат данных из бэкапа | +| 13 | `dim_routes_no_gaps.sql` | Проверка SCD2-интервалов (без «исчезнувших») | +| 14 | `airport_traffic_exists.sql` | Не пуста + NULL в ключе | +| 15 | `route_performance_exists.sql` | Не пуста + NULL в ключе | +| 16 | `monthly_overview_exists.sql` | Не пуста + NULL в ключе | +| 17 | `passenger_loyalty_exists.sql` | Не пуста + NULL в ключе | + +### DAG-файл + +| # | Файл | Описание | +|---|------|----------| +| 18 | `airflow/dags/bookings_validate.py` | DAG с TaskGroup по слоям | + +### Тест + +| # | Файл | Описание | +|---|------|----------| +| 19 | Дополнение `tests/test_dags_smoke.py` | Smoke-тест структуры DAG | + +--- + +## DAG-файл: `bookings_validate.py` + +```python +""" +Валидационный DAG: самопроверка студенческих заданий. + +Запускается вручную в Airflow UI после реализации заданий. +Таски сгруппированы по слоям — студент видит, где именно проблема. +""" + +from datetime import timedelta +from logging import getLogger + +import pendulum +from airflow import DAG +from airflow.providers.postgres.operators.postgres import PostgresOperator +from airflow.utils.task_group import TaskGroup + +GREENPLUM_CONN_ID = "greenplum_conn" +log = getLogger(__name__) + +default_args = { + "owner": "airflow", + "retries": 0, # Без ретраев — студент должен увидеть ошибку сразу + "retry_delay": timedelta(seconds=10), +} + +with DAG( + dag_id="bookings_validate", + start_date=pendulum.datetime(2017, 1, 1, tz="UTC"), + schedule=None, # Только ручной запуск + catchup=False, + max_active_runs=1, + template_searchpath="/sql", + default_args=default_args, + tags=["demo", "bookings", "greenplum", "validate"], + description="Валидация студенческих заданий: ODS, DDS, DM", +) as dag: + + with TaskGroup("validate_ods") as validate_ods: + check_ods_airplanes = PostgresOperator( + task_id="check_ods_airplanes_rowcount", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/ods_airplanes_rowcount.sql", + ) + check_ods_seats = PostgresOperator( + task_id="check_ods_seats_rowcount", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/ods_seats_rowcount.sql", + ) + check_ods_dup_bk = PostgresOperator( + task_id="check_ods_no_dup_bk", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/ods_no_dup_bk.sql", + ) + check_ods_pks = PostgresOperator( + task_id="check_ods_no_null_pks", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/ods_no_null_pks.sql", + ) + + with TaskGroup("validate_dds") as validate_dds: + check_dim_airplanes = PostgresOperator( + task_id="check_dim_airplanes_exists", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/dim_airplanes_exists.sql", + ) + check_dim_passengers = PostgresOperator( + task_id="check_dim_passengers_exists", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/dim_passengers_exists.sql", + ) + check_dim_passengers_dup = PostgresOperator( + task_id="check_dim_passengers_no_dup_bk", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/dim_passengers_no_dup_bk.sql", + ) + check_dim_routes = PostgresOperator( + task_id="check_dim_routes_exists", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/dim_routes_exists.sql", + ) + + # SCD2 активный тест: цепочка backup → mutate → load → check → restore + scd2_backup = PostgresOperator( + task_id="scd2_backup", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/dim_routes_scd2_backup.sql", + ) + scd2_mutate = PostgresOperator( + task_id="scd2_mutate", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/dim_routes_scd2_mutate.sql", + ) + scd2_run_load = PostgresOperator( + task_id="scd2_run_student_load", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_routes_load.sql", # студенческий load-скрипт! + ) + scd2_check = PostgresOperator( + task_id="scd2_check", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/dim_routes_scd2_check.sql", + ) + scd2_restore = PostgresOperator( + task_id="scd2_restore", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/dim_routes_scd2_restore.sql", + trigger_rule="all_done", # Откат ВСЕГДА, даже если check упал + ) + + check_dim_routes_gaps = PostgresOperator( + task_id="check_dim_routes_no_gaps", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/dim_routes_no_gaps.sql", + ) + + # SCD2 цепочка + scd2_backup >> scd2_mutate >> scd2_run_load >> scd2_check >> scd2_restore + + # no_gaps запускается после restore (на чистых данных) + scd2_restore >> check_dim_routes_gaps + + with TaskGroup("validate_dm") as validate_dm: + check_airport_traffic = PostgresOperator( + task_id="check_airport_traffic_exists", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/airport_traffic_exists.sql", + ) + check_route_performance = PostgresOperator( + task_id="check_route_performance_exists", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/route_performance_exists.sql", + ) + check_monthly_overview = PostgresOperator( + task_id="check_monthly_overview_exists", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/monthly_overview_exists.sql", + ) + check_passenger_loyalty = PostgresOperator( + task_id="check_passenger_loyalty_exists", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="validate/passenger_loyalty_exists.sql", + ) +``` + +--- + +## Активный тест SCD2: детализация SQL + +### `dim_routes_scd2_backup.sql` + +```sql +-- Бэкап текущего состояния перед тестом SCD2. +-- Используем обычные таблицы (не TEMP) — между тасками Airflow +-- TEMP-таблицы не сохраняются (каждый таск = отдельная транзакция). + +DROP TABLE IF EXISTS _validate_bk_ods_routes; +CREATE TABLE _validate_bk_ods_routes AS SELECT * FROM ods.routes; + +DROP TABLE IF EXISTS _validate_bk_dim_routes; +CREATE TABLE _validate_bk_dim_routes AS SELECT * FROM dds.dim_routes; + +-- Cleanup служебной таблицы от предыдущего запуска (на случай если restore не доехал) +DROP TABLE IF EXISTS _validate_scd2_target; +``` + +### `dim_routes_scd2_mutate.sql` + +```sql +-- Мутация: сдвигаем departure_time у одного маршрута на 1 час. +-- Это должно изменить hashdiff → SCD2 должен закрыть старую версию. +-- +-- Сохраняем route_no тестового маршрута в служебную таблицу _validate_scd2_target, +-- чтобы check-скрипт точно знал, какой маршрут проверять (а не угадывал по побочным эффектам). + +DO $$ +DECLARE + v_route TEXT; + v_old_time TIME; +BEGIN + -- Берём первый маршрут, у которого departure_time заполнен + SELECT route_no, departure_time + INTO v_route, v_old_time + FROM ods.routes + WHERE departure_time IS NOT NULL + ORDER BY route_no + LIMIT 1; + + IF v_route IS NULL THEN + RAISE EXCEPTION 'FAILED: ods.routes пуста или нет маршрутов с departure_time. Загрузите STG→ODS перед проверкой.'; + END IF; + + -- Запоминаем тестовый маршрут в служебную таблицу + DROP TABLE IF EXISTS _validate_scd2_target; + CREATE TABLE _validate_scd2_target AS + SELECT v_route AS route_no; + + -- Сдвигаем время на 1 час у всех записей этого маршрута + UPDATE ods.routes + SET departure_time = departure_time + INTERVAL '1 hour' + WHERE route_no = v_route; + + RAISE NOTICE 'SCD2 TEST: маршрут % — departure_time сдвинут с % на %', + v_route, v_old_time, v_old_time + INTERVAL '1 hour'; +END $$; +``` + +### `dim_routes_scd2_check.sql` + +```sql +-- Проверяем, что SCD2-логика студента сработала корректно. +-- Читаем route_no тестового маршрута из служебной таблицы _validate_scd2_target +-- (создана на шаге mutate), а не угадываем по побочным эффектам. +DO $$ +DECLARE + v_route TEXT; + v_version_count BIGINT; + v_closed_count BIGINT; + v_open_count BIGINT; + v_old_hash TEXT; + v_new_hash TEXT; + v_gap_count BIGINT; +BEGIN + -- Читаем тестовый маршрут из служебной таблицы + SELECT route_no INTO v_route FROM _validate_scd2_target LIMIT 1; + + IF v_route IS NULL THEN + RAISE EXCEPTION 'FAILED: служебная таблица _validate_scd2_target пуста. Шаг mutate не выполнился?'; + END IF; + + -- Если dim_routes пуста — load не запустился + IF NOT EXISTS (SELECT 1 FROM dds.dim_routes WHERE route_bk = v_route) THEN + RAISE EXCEPTION E'FAILED: dds.dim_routes не содержит маршрут % после запуска load.\n' + 'Проверьте sql/dds/dim_routes_load.sql.', v_route; + END IF; + + -- Проверка a: Ровно 2 версии тестового маршрута (было 1, стало 2 после мутации) + SELECT COUNT(*) INTO v_version_count + FROM dds.dim_routes WHERE route_bk = v_route; + + IF v_version_count < 2 THEN + RAISE EXCEPTION E'FAILED: Маршрут % — найдена % версия (ожидается 2: старая закрытая + новая открытая).\n' + 'SCD2 должен был создать новую версию после изменения departure_time.', v_route, v_version_count; + END IF; + + IF v_version_count > 2 THEN + RAISE EXCEPTION E'FAILED: Маршрут % — найдено % версий (ожидается 2).\n' + 'Возможно, load создаёт лишние дубликаты. Проверьте условие NOT EXISTS при INSERT.', v_route, v_version_count; + END IF; + + -- Проверка b: Старая версия закрыта (valid_to IS NOT NULL) + SELECT COUNT(*) INTO v_closed_count + FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NOT NULL; + + IF v_closed_count = 0 THEN + RAISE EXCEPTION E'FAILED: Маршрут % — SCD2 не закрыл старую версию (valid_to IS NULL у всех версий).\n' + 'Подсказка: hashdiff изменился (departure_time сдвинут на 1 час),\n' + 'но ваш load-скрипт не обнаружил это изменение.\n' + 'Проверьте:\n' + ' 1. Формулу hashdiff — включает ли она departure_time?\n' + ' 2. Логику сравнения hashdiff (UPDATE ... SET valid_to = CURRENT_DATE WHERE hashdiff <> новый_hashdiff)', v_route; + END IF; + + -- Проверка c: Новая версия открыта (valid_to IS NULL) + SELECT COUNT(*) INTO v_open_count + FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NULL; + + IF v_open_count <> 1 THEN + RAISE EXCEPTION E'FAILED: Маршрут % — ожидается ровно 1 открытая версия (valid_to IS NULL), найдено %.\n' + 'Подсказка: SCD2 должен вставить новую строку с valid_to = NULL.', v_route, v_open_count; + END IF; + + -- Проверка d: hashdiff старой ≠ hashdiff новой (мутация действительно отразилась) + SELECT hashdiff INTO v_old_hash + FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NOT NULL + ORDER BY valid_from DESC LIMIT 1; + + SELECT hashdiff INTO v_new_hash + FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NULL; + + IF v_old_hash = v_new_hash THEN + RAISE EXCEPTION E'FAILED: Маршрут % — hashdiff старой и новой версий совпадают.\n' + 'Мутация сдвинула departure_time на 1 час, но hashdiff не изменился.\n' + 'Проверьте, что departure_time входит в формулу hashdiff.', v_route; + END IF; + + -- Проверка e: Нет «дыры» между valid_to старой и valid_from новой + SELECT COUNT(*) INTO v_gap_count + FROM dds.dim_routes AS old_v + JOIN dds.dim_routes AS new_v + ON old_v.route_bk = new_v.route_bk + WHERE old_v.route_bk = v_route + AND old_v.valid_to IS NOT NULL + AND new_v.valid_to IS NULL + AND old_v.valid_to <> new_v.valid_from; + + IF v_gap_count > 0 THEN + RAISE EXCEPTION E'FAILED: Маршрут % — «дыра» между версиями:\n' + 'valid_to старой ≠ valid_from новой.\n' + 'Подсказка: полуоткрытый интервал [valid_from, valid_to).\n' + 'valid_from новой версии должен = valid_to старой (обычно CURRENT_DATE).', v_route; + END IF; + + RAISE NOTICE 'PASSED: SCD2 корректен для маршрута %. 2 версии, hashdiff различаются, «дыр» нет.', v_route; +END $$; +``` + +### `dim_routes_scd2_restore.sql` + +```sql +-- Откат данных после теста SCD2. +-- Выполняется ВСЕГДА (trigger_rule="all_done"), даже если check упал. +-- +-- Безопасность: если backup-шаг не создал таблицы (сбой на backup), +-- откат НЕ трогает live-данные — просто чистит служебные таблицы. +-- Это гарантирует, что restore никогда не сломает ods.routes / dds.dim_routes. + +DO $$ +DECLARE + v_has_ods_backup BOOLEAN; + v_has_dim_backup BOOLEAN; +BEGIN + -- Проверяем существование backup-таблиц через to_regclass + -- (ищет по search_path — совпадает с тем, как CREATE TABLE их создал) + v_has_ods_backup := to_regclass('_validate_bk_ods_routes') IS NOT NULL; + v_has_dim_backup := to_regclass('_validate_bk_dim_routes') IS NOT NULL; + + -- Восстанавливаем ods.routes только если бэкап существует + IF v_has_ods_backup THEN + TRUNCATE ods.routes; + INSERT INTO ods.routes SELECT * FROM _validate_bk_ods_routes; + RAISE NOTICE 'RESTORE: ods.routes восстановлена из бэкапа'; + ELSE + RAISE NOTICE 'RESTORE: бэкап ods.routes не найден — пропускаем (backup-шаг не завершился?)'; + END IF; + + -- Восстанавливаем dds.dim_routes только если бэкап существует + IF v_has_dim_backup THEN + TRUNCATE dds.dim_routes; + INSERT INTO dds.dim_routes SELECT * FROM _validate_bk_dim_routes; + RAISE NOTICE 'RESTORE: dds.dim_routes восстановлена из бэкапа'; + ELSE + RAISE NOTICE 'RESTORE: бэкап dds.dim_routes не найден — пропускаем'; + END IF; +END $$; + +-- Cleanup служебных таблиц (безусловно, IF EXISTS) +DROP TABLE IF EXISTS _validate_bk_ods_routes; +DROP TABLE IF EXISTS _validate_bk_dim_routes; +DROP TABLE IF EXISTS _validate_scd2_target; + +-- Учебный комментарий: Мы восстанавливаем данные из бэкапа, чтобы тест +-- не оставлял «мусорных» версий в dim_routes. Это стандартный паттерн +-- для интеграционных тестов: setup → act → assert → teardown. +-- IF EXISTS проверки гарантируют, что restore безопасен при любом сценарии сбоя. +``` + +--- + +## Smoke-тест DAG (дополнение `test_dags_smoke.py`) + +Добавить новый тест-класс: + +```python +class TestBookingsValidate: + """Smoke-тесты DAG bookings_validate.""" + + def test_dag_loads(self): + dag = _load_dag("airflow.dags.bookings_validate") + assert dag is not None + + def test_expected_tasks(self): + dag = _load_dag("airflow.dags.bookings_validate") + expected = { + # ODS + "validate_ods.check_ods_airplanes_rowcount", + "validate_ods.check_ods_seats_rowcount", + "validate_ods.check_ods_no_dup_bk", + "validate_ods.check_ods_no_null_pks", + # DDS + "validate_dds.check_dim_airplanes_exists", + "validate_dds.check_dim_passengers_exists", + "validate_dds.check_dim_passengers_no_dup_bk", + "validate_dds.check_dim_routes_exists", + "validate_dds.scd2_backup", + "validate_dds.scd2_mutate", + "validate_dds.scd2_run_student_load", + "validate_dds.scd2_check", + "validate_dds.scd2_restore", + "validate_dds.check_dim_routes_no_gaps", + # DM + "validate_dm.check_airport_traffic_exists", + "validate_dm.check_route_performance_exists", + "validate_dm.check_monthly_overview_exists", + "validate_dm.check_passenger_loyalty_exists", + } + assert expected.issubset(dag.task_dict.keys()) + + def test_scd2_chain(self): + """SCD2 цепочка backup → mutate → load → check → restore.""" + dag = _load_dag("airflow.dags.bookings_validate") + _assert_direct_edge(dag, "validate_dds.scd2_backup", "validate_dds.scd2_mutate") + _assert_direct_edge(dag, "validate_dds.scd2_mutate", "validate_dds.scd2_run_student_load") + _assert_direct_edge(dag, "validate_dds.scd2_run_student_load", "validate_dds.scd2_check") + _assert_direct_edge(dag, "validate_dds.scd2_check", "validate_dds.scd2_restore") + + def test_no_gaps_after_restore(self): + """no_gaps должен выполняться после restore (на чистых данных).""" + dag = _load_dag("airflow.dags.bookings_validate") + _assert_direct_edge(dag, "validate_dds.scd2_restore", "validate_dds.check_dim_routes_no_gaps") + + def test_restore_trigger_rule(self): + """restore должен выполняться всегда (all_done), даже если check упал.""" + dag = _load_dag("airflow.dags.bookings_validate") + restore_task = dag.task_dict["validate_dds.scd2_restore"] + assert restore_task.trigger_rule == "all_done" +``` + +--- + +## Порядок реализации + +1. Создать каталог `sql/validate/` +2. Написать SQL-скрипты (17 файлов) — начать с простых (ODS, DM), затем DDS, затем SCD2 +3. Написать DAG `bookings_validate.py` +4. Дополнить `tests/test_dags_smoke.py` +5. `make test` — smoke-тесты проходят +6. Ручная проверка на стенде (если поднят): + - Trigger DAG в Airflow UI + - Все ODS/DDS/DM зелёные (на solution-ветке) + - SCD2 активный тест: backup → mutate → load → check (зелёный) → restore +7. Коммит + +--- + +## Верификация + +### Автоматическая + +```bash +make test # smoke-тест DAG-структуры +``` + +### Ручная (на стенде) + +1. `make up && make ddl-gp` → запустить STG → ODS → DDS → DM +2. Trigger `bookings_validate` в Airflow UI +3. Проверить: все таски зелёные +4. Проверить SCD2: в логах `scd2_check` видно `PASSED: SCD2 корректен для маршрута ...` +5. Проверить restore: `ods.routes` и `dds.dim_routes` не изменились после теста + +### Сценарий «студент ещё не реализовал» + +На main-ветке (с DDL-заглушками): +- ODS-таски: FAILED (таблицы пусты) → дружелюбное сообщение +- DDS-таски: FAILED → сообщение «Реализуйте загрузку» +- DM-таски: FAILED → сообщение «Реализуйте загрузку» +- SCD2-тест: `scd2_run_student_load` пройдёт (заглушка `SELECT 1;` — валидный SQL), + но `scd2_check` упадёт (dim_routes не обновилась, версий < 2) + → `scd2_restore` всё равно выполнится (`trigger_rule="all_done"`) + +--- + +## Открытые вопросы + +1. **`dim_passengers_no_dup_bk` — отдельный таск или объединить с `exists`?** + Отдельный таск даёт точнее диагностику, но увеличивает число тасков. + Рекомендация: объединить проверки в один файл `dim_passengers_exists.sql` + (как сделано для `dim_airplanes_exists`). + +2. **Нужна ли проверка `total_seats` в `dim_airplanes`?** + `total_seats` вычисляется агрегацией из `ods.seats`. Если студент забудет + этот JOIN — поле будет NULL. Можно добавить `SELECT COUNT(*) WHERE total_seats IS NULL`. + Рекомендация: добавить в `dim_airplanes_exists.sql`. + +3. **Бэкап SCD2: обычные таблицы vs TEMP?** + Каждый таск Airflow — отдельная транзакция → TEMP-таблицы не сохраняются. + Используем обычные таблицы с префиксом `_validate_bk_`. Риск: если DAG упадёт + между backup и restore, таблицы останутся. Cleanup: `scd2_restore` делает + `DROP TABLE IF EXISTS`, поэтому при следующем запуске проблем не будет. + +--- + +## Ключевые файлы + +| Файл | Роль | +|------|------| +| `airflow/dags/bookings_validate.py` | DAG: TaskGroup по слоям | +| `sql/validate/*.sql` | 17 SQL-скриптов проверок | +| `sql/dds/dim_routes_load.sql` | Студенческий load (вызывается из SCD2-теста) | +| `tests/test_dags_smoke.py` | Smoke-тесты DAG-структуры | +| `docs/design/assignment_design.md` | Дизайн (секция 4 — источник требований) | diff --git a/docs/archive/architecture_review.md b/docs/archive/architecture_review.md new file mode 100644 index 0000000..39fd69b --- /dev/null +++ b/docs/archive/architecture_review.md @@ -0,0 +1,304 @@ +# Ревью архитектуры слоёв DWH: оценка учебной ценности + +> Дата: 2026-03-01 (ревью), 2026-03-11 (закрытие) +> Статус: **завершён** — P0/P1/P2 выполнены, P3 отложены (покрыты другими документами) +> Контекст: оценка текущей конструкции слоёв с точки зрения учебных целей + +## Context + +Стенд — курсовая работа и эталон для менти-джунов. Они понесут эти паттерны на свою первую работу. Оцениваем по двум осям: **production-ready** (чтобы не стыдно было показать на собеседовании) и **KISS** (чтобы джун не утонул в сложности). + +Текущее состояние: 95 SQL-файлов, 5 слоёв (STG→ODS→DDS→DM), 9 DAG-ов, полная Star Schema с SCD2, DQ на каждом шаге. Реализована 1 из 5 витрин DM. + +--- + +## СИЛЬНЫЕ СТОРОНЫ (что уже отлично) + +### 1. Паттерн load → DQ на каждом шаге — эталонный +Каждая сущность в каждом слое имеет тройку файлов `_ddl.sql` / `_load.sql` / `_dq.sql`. DAG-и обеспечивают порядок load→dq→next. Smoke-тесты проверяют рёбра графа. Студенты усвоят: **DQ — не опция, а часть пайплайна**. + +### 2. UPSERT через UPDATE + INSERT — production-grade для Greenplum +Не DELETE+INSERT (дорого на AO-таблицах), не MERGE (нет в GP6). `IS DISTINCT FROM` для null-safe сравнения — деталь, которую даже опытные инженеры забывают. + +### 3. Антипаттерн-обучение в DM (distribution by date) +Комментарий в `dm/sales_report_ddl.sql` объясняет **почему** нельзя распределять по дате, с конкретными причинами (Load Skew, Processing Skew). Это «почему нет» — именно то, что не дают учебники. + +### 4. SCD2 в dim_routes — полный и корректный +Все три кейса: закрытие изменённых версий (hashdiff), закрытие исчезнувших маршрутов, вставка новых версий с правильной логикой valid_from. DQ проверяет пересечение интервалов. Готовый reference implementation. + +### 5. Point-in-time lookup в факте — ключевой навык +```sql +LEFT JOIN dds.dim_routes AS rte + ON rte.route_bk = flt.route_no + AND flt.scheduled_departure::DATE >= rte.valid_from + AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to) +``` +Многие продакшн-DWH ошибаются, присоединяя только текущую версию. + +### 6. HWM-инкрементальность в DM — самовосстанавливающийся пайплайн +`MAX(_load_ts)` + TEMP TABLE для однократной агрегации — канон MPP. Пайплайн сам «догоняет» пропущенные дни. + +### 7. DAG-графы корректно отражают зависимости данных +Параллельность airports/airplanes, gates на routes (нужны оба), факт после всех измерений. Smoke-тесты проверяют и наличие, и **отсутствие** рёбер (параллельность). + +### 8. ANALYZE после каждой загрузки +GP-специфичная best practice, которую забывают даже опытные команды. + +### 9. Идемпотентные STG-загрузки +`NOT EXISTS (... WHERE _load_id = '{{ run_id }}')` — простой, корректный, понятный паттерн для retry-safe загрузок. + +--- + +## ЗАМЕЧАНИЯ И ЗАДАЧИ ДЛЯ ДОРАБОТКИ + +### P0: Фактическая ошибка (исправить до показа студентам) + +- [x] **ODS batch resolver теряет данные при двух STG-запусках подряд** + - Сценарий: STG run_1 загружает день N, STG run_2 загружает день N+1, затем ODS запускается + - `_resolve_stg_batch_id()` выбирает только последний согласованный batch (`run_2`) + - Все ODS load-скрипты фильтруют `WHERE _load_id = 'run_2'` → данные `run_1` навсегда пропущены + - **Справочники** (airports, airplanes, routes, seats): проблемы нет — full snapshot, `run_2` содержит всё + - **Транзакционные таблицы** (bookings, tickets, flights, segments, boarding_passes): **потеря данных** — инкрементальные записи `run_1` никогда не попадут в ODS + - Корень проблемы: batch resolver проектировался для согласованности справочников (INTERSECT), но тот же single-batch фильтр применяется к транзакционным таблицам, где нужны **все необработанные** batch-и + - **Нужно**: разделить логику — для транзакционных таблиц загружать все batch-и с `_load_ts > MAX(_load_ts в ODS)` (аналог HWM из DM), для справочников — по-прежнему последний согласованный + - Файлы: `airflow/dags/bookings_to_gp_ods.py`, `sql/ods/bookings_load.sql`, `sql/ods/tickets_load.sql`, `sql/ods/flights_load.sql`, `sql/ods/segments_load.sql`, `sql/ods/boarding_passes_load.sql` + +- [x] **Противоречие в distribution key для airport_traffic** + - `bookings_dm_design.md` (строка 182): `DISTRIBUTED BY (traffic_date)` + - `sales_report_ddl.sql`: явно объясняет, почему distribution by date — антипаттерн + - **Нужно**: исправить на `DISTRIBUTED BY (airport_sk)` в дизайн-документе + - Файл: `docs/design/bookings_dm_design.md` + +### P1: Высокий эффект, минимум усилий (комментарии и документация) + +- [x] **Нет объяснения «почему не SERIAL» в генерации SK** + - `MAX(sk) + ROW_NUMBER()` корректен для GP, но студент на PostgreSQL/Snowflake будет использовать `IDENTITY`/`SEQUENCE` + - **Нужно**: 4-строчный комментарий в `sql/dds/dim_airports_load.sql` + +- [x] **Факт без суррогатного ключа — не объяснено «почему»** + - Натуральный (ticket_no, flight_id) как grain — правильное Kimball-моделирование + - **Нужно**: комментарий в `sql/dds/fact_flight_sales_ddl.sql` + +- [x] **Нет упоминания cross-DAG зависимостей** + - STG, ODS, DDS, DM — отдельные DAG-и с `schedule=None`, студент может не понять порядок + - **Нужно**: комментарий в docstring каждого DAG или `docs/dag_execution_order.md` + +- [x] **Late-arriving dimensions не упомянуты** + - Факт делает LEFT JOIN → `passenger_sk = NULL` при опоздании; нет механизма исправления + - **Нужно**: комментарий в `sql/dds/fact_flight_sales_load.sql` у LEFT JOIN-ов + +- [x] **Batch resolver недообъяснён** + - `_resolve_stg_batch_id` с INTERSECT по 4 таблицам — нет комментария **зачем** нужна согласованность + - **Нужно**: комментарий в `airflow/dags/bookings_to_gp_ods.py` перед SQL-запросом + +- [x] **`helpers/greenplum.py`** — удалён вместе с CSV-пайплайном (перенесён в airflow-manual) + +### P2: Средние усилия, заметное улучшение качества + +- [x] **Явный storage type для всех таблиц + AO где возможно** ✅ РЕШЕНИЕ ПРИНЯТО + - 18 из 28 таблиц имели неявный heap (нет `WITH`) — теперь выбор сделан явно + - **Целевая раскладка по storage:** + - **AO Row + zstd**: `dds.dim_calendar` (узкая таблица, column-store не даёт выигрыша) + - **AO Row + zstd**: ODS snapshot-справочники (`airports`, `airplanes`, `routes`, `seats`) + — перевести загрузку с UPSERT на TRUNCATE+INSERT (честнее для full snapshot семантики) + - **AO Row + zstd**: `dds.dim_tariffs` (только INSERT, нет UPDATE) + - **AO Row + zstd**: `dm.route_performance` (full rebuild, по дизайну) + - **Heap (явный)**: ODS транзакционные (`bookings`, `tickets`, `flights`, `segments`, + `boarding_passes`) — row-level UPDATE при SCD1 UPSERT + - **Heap (явный)**: DDS измерения с UPDATE (`dim_airports`, `dim_airplanes`, + `dim_passengers`, `dim_routes`) и `fact_flight_sales` + - **Heap (явный)**: DM витрины с UPSERT (`sales_report` и будущие HWM-витрины) + - К каждой таблице добавить комментарий, объясняющий выбор storage type + - Файлы: все `*_ddl.sql` в ods/, dds/, dm/ + переписать 4 ODS snapshot load-скрипта + - См. ADR-3 + +- [x] **Дублирование hashdiff CTE в dim_routes_load.sql** + - md5(COALESCE(...)) повторяется в Statement 1 и Statement 2, ROW_NUMBER() — 3 раза + - **Решение**: вынесено в CREATE TEMP TABLE tmp_routes_src ON COMMIT DROP ✅ ВЫПОЛНЕНО + - Файл: `sql/dds/dim_routes_load.sql` + +- [x] **Несогласованность нейминга STG vs ODS+** ✅ ВЫПОЛНЕНО + - STG: `batch_id`, `load_dttm`, `src_created_at_ts` → переименованы в канон `_load_id`, `_load_ts`, `event_ts` + - Единый словарь во всех слоях снижает когнитивную нагрузку + - Секция 6 «Переходный маппинг» удалена из `naming_conventions.md` как неактуальная + - Файлы: 27 STG SQL + ODS load-скрипты + `naming_conventions.md` + тесты + +- [x] **Дублирование CTE в ODS load-скриптах** + - `WITH src AS (...)` копируется 2-3 раза в каждом из 9 ODS load-файлов + - **Решение**: TEMP TABLE для самых сложных (airports, flights, routes); простые — оставить + - Файлы: `sql/ods/airports_load.sql`, `sql/ods/flights_load.sql`, `sql/ods/routes_load.sql` + - *Заметка*: Для всех транзакционных таблиц ODS внедрен паттерн TEMP TABLE для надежной работы HWM. + +- [x] **DM слой спроектирован** + - 5 витрин: `sales_report`, `route_performance`, `passenger_loyalty`, `airport_traffic`, `monthly_overview` + - `sales_report`, `route_performance` — эталонные реализации; `passenger_loyalty`, `airport_traffic`, `monthly_overview` — задания для студентов + - `route_performance` — full rebuild + AO Column Store + +### P3: Отложено (покрыто другими документами) + +- [~] **Крутая лестница сложности ODS→DDS** + - Покрыто: `docs/assignment/analyst_spec.md` содержит рекомендуемый порядок выполнения + (STG → ODS → DDS SCD1 → DDS SCD2 → DM от простого к сложному) + +- [~] **DQ без переиспользуемых функций** + - Отложено: переусложнение для учебного стенда (AGENTS.md: KISS) + +- [~] **Нет документа по стратегии distribution** + - Покрыто: подсказки по DK есть в `analyst_spec.md` и комментариях к каждому DDL + +- [~] **Отсутствующие паттерны** (комментарии/заметки) + - Покрыто частично: partitioning и exchange partition описаны в ADR-2 (этот документ). + SCD3/6 и data lineage — вне скоупа курсовой + +--- + +## ПРИНЯТЫЕ АРХИТЕКТУРНЫЕ РЕШЕНИЯ + +### ADR-1: Города, страны, модели самолётов — атрибуты измерений, не отдельные справочники + +**Рассматривалось**: выделить `dim_city`, `dim_country`, `dim_airplane_model` как отдельные +измерения со своими суррогатными ключами. + +**Решение**: оставить `city`, `country` как атрибуты `dim_airports`, а `model` — как атрибут +`dim_airplanes`. Не создавать отдельные справочники. + +**Обоснование**: +1. **Star vs Snowflake.** Kimball-методология рекомендует «wide and flat» измерения. + Вынос атрибутов в подтаблицы превращает star schema в snowflake — добавляет 2-3 JOIN-а + в каждый запрос к факту без аналитического выигрыша. Для учебного стенда star schema — + правильный эталон. +2. **Нет самостоятельной сущности в домене.** Город — JSON-атрибут аэропорта в источнике + (`airport_name::json->>'ru'`). У него нет своего бизнес-ключа, жизненного цикла, + независимых атрибутов. Модель самолёта — аналогично. +3. **Когнитивная нагрузка.** Лестница ODS→DDS уже крутая (6 измерений + 1 факт + SCD2). + Добавление 2-3 измерений усложнит стенд без пропорционального обучающего эффекта. + +**Когда отдельное измерение оправдано** (для справки студентам): +- Город имеет собственные атрибуты из другого источника (население, регион, координаты) + → `dim_geography` как outrigger-измерение +- Модель самолёта имеет независимые характеристики (производитель, сертификация, конфигурации) + → `dim_aircraft_type` +- В Data Vault — `hub_city` / `hub_country` как самостоятельные бизнес-объекты (другая парадигма) + +### ADR-2: Heap + UPSERT вместо AO + партиционирование + exchange partition + +**Контекст**: Greenplum широко распространён в РФ — на него активно мигрировали при +импортозамещении с Teradata и Exadata. Именно поэтому GP выбран для курсовой: опыт работы +с ним будет напрямую релевантен первой работе студента. Тем важнее, чтобы студенты понимали, +как устроены реальные GP-хранилища, даже если стенд использует упрощённый подход. + +**Рассматривалось**: использовать production-паттерн крупных GP-хранилищ: +- AO Column Store (сжатие zlib/zstd, векторное чтение, колоночное хранение) +- Range-партиционирование по дате (`PARTITION BY RANGE (flight_date)`) +- Обновление через замену партиций (`ALTER TABLE EXCHANGE PARTITION`) или + `DELETE + INSERT` в рамках одной партиции вместо row-level UPDATE + +**Решение**: партиционирование не применяем (учебные объёмы). Для storage — +дифференцированный подход: heap для таблиц с UPDATE, AO для иммутабельных +(см. ADR-3 с полной раскладкой). + +**Обоснование**: +1. **Универсальность паттерна.** UPSERT через UPDATE + INSERT работает в PostgreSQL, + Snowflake, BigQuery, Redshift — везде. Exchange partition — GP-специфика + (`ALTER TABLE ... EXCHANGE PARTITION FOR (...) WITH TABLE tmp_...`). + Студент, освоив UPSERT, сможет применить его на любой платформе. +2. **Объём данных.** На учебных ~100K строк партиционирование не даёт partition pruning + эффекта, зато утраивает DDL (стратегия, sub-partitions, retention policy). + Выигрыш нулевой, когнитивная нагрузка — существенная. +3. **Простота ментальной модели.** «Вот строка, она обновилась» понятнее, чем «вот партиция, + она заменилась целиком». Второй паттерн требует понимания storage engine, что выходит + за рамки первого курса DWH. + +**Что студенту важно знать про реальный GP** (для менти): + +На продакшн-хранилищах с десятками и сотнями миллионов строк подход меняется принципиально: + +| Аспект | Стенд (учебный) | Продакшн (реальный GP) | +|--------|-----------------|----------------------| +| Хранение фактов | Heap (row-oriented) | AO Column Store (сжатие, колонки) | +| Партиционирование | Нет | Range по дате (день/месяц) | +| Обновление | Row-level UPDATE | Exchange partition или DELETE+INSERT в партиции | +| Причина | UPDATE на AO «раздувает» таблицу (помечает строки deleted, дописывает новые) | | +| Когда переходить | > 10M строк, или когда VACUUM не справляется | | + +Типичный production-паттерн загрузки факта по дням: +```sql +-- 1. Собрать новую партицию во временную таблицу +CREATE TABLE tmp_fact_20170102 (LIKE dds.fact_flight_sales) + WITH (appendonly=true, orientation=column, compresstype=zstd); +INSERT INTO tmp_fact_20170102 SELECT ... FROM ods... WHERE flight_date = '2017-01-02'; + +-- 2. Атомарно заменить партицию (без DELETE, без UPDATE) +ALTER TABLE dds.fact_flight_sales + EXCHANGE PARTITION FOR ('2017-01-02') WITH TABLE tmp_fact_20170102; + +-- 3. Удалить временную таблицу (теперь в ней старые данные) +DROP TABLE tmp_fact_20170102; +``` + +Преимущества exchange partition: +- Нет row-level UPDATE → нет bloat, не нужен VACUUM +- AO Column Store даёт 5-10x сжатие и быстрые аналитические скана +- Partition pruning: запрос `WHERE flight_date = '2017-01-02'` читает только одну партицию +- Атомарность: EXCHANGE — одна DDL-команда, нет окна неконсистентности + +### ADR-3: Явный storage type для каждой таблицы + AO где нет UPDATE + +**Проблема**: 18 из 28 таблиц в ODS/DDS/DM создаются без `WITH`-клаузы. GP по умолчанию +создаёт heap, но студент не видит осознанного выбора — таблица «просто создаётся». +В учебном стенде каждое решение должно быть видимым и объяснённым. + +**Решение**: добавить явный `WITH (...)` ко всем таблицам. Где row-level UPDATE не нужен — +перевести на AO (Row или Column) с компрессией. + +**Целевая раскладка storage по таблицам:** + +| Storage | Таблицы | Почему | +|---------|---------|--------| +| **AO Column** zstd | `dds.dim_calendar` | Write-once (generate_series), никогда не обновляется. Колоночное хранение идеально для аналитических скан. | +| **AO Column** zstd | `dm.route_performance` | Full rebuild (TRUNCATE+INSERT), чисто аналитические чтения. | +| **AO Row** zstd | STG: все 9 таблиц | Уже реализовано. Append-only, иммутабельные батчи. Примечание: используем **zstd (level 1)** вместо zlib, так как он обеспечивает более высокую скорость декомпрессии и лучшее сжатие в современных GP-кластерах (6.0+). | +| **AO Row** zstd | ODS snapshot: `airports`, `airplanes`, `routes`, `seats` | Полный snapshot каждый раз. Перевести загрузку с UPSERT на TRUNCATE+INSERT — честнее для семантики «текущий срез». | +| **AO Row** zstd | `dds.dim_tariffs` | Только INSERT новых тарифов, UPDATE не используется. | +| **Heap** (явный) | ODS транзакционные: `bookings`, `tickets`, `flights`, `segments`, `boarding_passes` | Row-level UPDATE при SCD1 UPSERT. Heap обязателен. | +| **Heap** (явный) | DDS измерения с UPDATE: `dim_airports`, `dim_airplanes`, `dim_passengers`, `dim_routes` | SCD1/SCD2 UPSERT с row-level UPDATE. | +| **Heap** (явный) | `dds.fact_flight_sales` | UPDATE (is_boarded, seat_no меняются). | +| **Heap** (явный) | DM витрины с UPSERT: `sales_report` и будущие HWM-витрины | Row-level UPDATE при инкрементальном UPSERT. | + +**Учебная ценность**: студенты видят на практике три storage-стратегии в одном проекте: +1. AO Column — для иммутабельных аналитических таблиц (dim_calendar, route_performance) +2. AO Row — для append-only данных и snapshot-справочников (STG, ODS refs, dim_tariffs) +3. Heap — для таблиц с row-level UPDATE (ODS транзакции, DDS dims с UPSERT, факт, DM) + +И понимают **почему** выбор именно такой: UPDATE на AO = bloat + необходимость VACUUM. + +--- + +## ЧТО ОСТАВИТЬ КАК ЕСТЬ + +| Аспект | Почему не трогаем | +|--------|-------------------| +| Факт без SK | Правильное моделирование, нужен только комментарий (P1) | +| ODS DAG с 20 задачами | Не перегружает — параллельная структура наглядна на графе | +| 5 витрин DM | Правильное количество, каждая учит своему паттерну | +| PL/pgSQL DQ | Достаточно для учебного проекта, фреймворк — перебор | +| MAX+ROW_NUMBER для SK | Корректно для GP, нужен только комментарий (P1) | + +--- + +## Сводка по трудозатратам + +Все задачи P0–P2 выполнены. P3 отложены (покрыты другими документами). + +| Приоритет | Действие | Статус | +|-----------|----------|--------| +| ~~P0~~ | ODS batch resolver: разделить логику для справочников и транзакций | ✅ | +| ~~P0~~ | Исправить distribution key в airport_traffic | ✅ | +| ~~P1~~ | Добавить 7 точечных комментариев | ✅ | +| ~~P2~~ | Явный storage type + AO где нет UPDATE (ADR-3) | ✅ | +| ~~P2~~ | Рефакторинг hashdiff → TEMP TABLE | ✅ | +| ~~P2~~ | Переименовать STG поля в канон | ✅ | +| ~~P2~~ | TEMP TABLE для сложных ODS load-ов | ✅ | +| ~~P2~~ | Реализовать DM слой (5 витрин) | ✅ | +| P3 | Маршрут изучения DDS | Покрыто: `analyst_spec.md` | +| P3 | DQ-функции, distribution strategy, паттерны | Отложено | diff --git a/docs/assignment/README.md b/docs/assignment/README.md new file mode 100644 index 0000000..561bd40 --- /dev/null +++ b/docs/assignment/README.md @@ -0,0 +1,19 @@ +# Учебные задания + +## Техническое задание + +Основной документ: **[analyst_spec.md](analyst_spec.md)** — ТЗ от аналитика +с описанием всех таблиц, маппингами, бизнес-правилами и подсказками. + +## Эталон для изучения + +Перед началом работы изучите эталонный срез (витрина `dm.sales_report` +и вся её цепочка STG → ODS → DDS → DM): + +- SQL-скрипты: `sql/stg/`, `sql/ods/`, `sql/dds/`, `sql/dm/` +- DAG-файлы: `airflow/dags/` +- [Порядок запуска DAG-ов](../reference/dag_execution_order.md) + +## Для менторов + +Дизайн заданий и педагогическая логика: [docs/design/assignment_design.md](../design/assignment_design.md). diff --git a/docs/assignment/analyst_spec.md b/docs/assignment/analyst_spec.md new file mode 100644 index 0000000..42769db --- /dev/null +++ b/docs/assignment/analyst_spec.md @@ -0,0 +1,652 @@ +# Техническое задание: расширение DWH авиаперевозок + +> **Роль:** Вы — Data Engineer. Это ТЗ подготовлено аналитиком на основе +> бизнес-потребностей. Ваша задача — реализовать описанные таблицы и загрузки, +> опираясь на эталонный срез (витрина `dm.sales_report` и вся её цепочка). +> +> **Эталон для изучения:** +> - STG: весь слой — `bookings`, `tickets`, `flights`, `segments`, `boarding_passes`, +> `airports`, `airplanes`, `seats`, `routes` (реализован как образец) +> - ODS: `bookings`, `tickets`, `segments`, `flights`, `boarding_passes`, `airports`, `routes` +> - DDS: `dim_airports`, `dim_tariffs`, `dim_calendar`, `fact_flight_sales` +> - DM: `sales_report` +> +> SQL-скрипты эталона лежат в `sql/stg/`, `sql/ods/`, `sql/dds/`, `sql/dm/`. + +--- + +## Рекомендуемый порядок выполнения + +> **Примечание:** STG-слой полностью реализован в эталоне (все 9 таблиц), `ods.routes` +> тоже эталонный. Ваше задание начинается с ODS: `airplanes` и `seats`. + +1. **ODS** — `airplanes`, `seats` (практика TRUNCATE+INSERT по аналогии с `ods.airports`) +2. **DDS** — `dim_airplanes`, `dim_passengers` (SCD1 — новые измерения) +3. **DDS** — `dim_routes` (SCD2 — ключевой вызов курсовой) +4. **DM** — `airport_traffic` (простая витрина, похожа на `sales_report`) +5. **DM** — `route_performance` (Full Rebuild, работа с SCD2-измерением) +6. **DM** — `monthly_overview` (двухуровневая агрегация) +7. **DM** — `passenger_loyalty` (самая сложная, пересчёт истории) + +Порядок выстроен от простого к сложному. Каждый шаг опирается на опыт +предыдущего. Вы можете двигаться в ином порядке, но убедитесь, что зависимости +слоёв соблюдены (ODS → DDS → DM). + +--- + +## Общие правила + +- **Нейминг полей:** см. `docs/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-файлы соответствующего слоя. +- **Идемпотентность:** каждый скрипт загрузки должен быть безопасен + при повторном запуске (не создавать дубликатов). +- **Шаблон `{{ run_id }}`:** используйте Jinja-шаблон Airflow для `_load_id`. + +--- + +## Почему STG-слой уже реализован + +STG-слой (все 9 таблиц) и `ods.routes` полностью реализованы в эталоне. Причина: + +Эталонный пайплайн использует **batch resolver** — механизм, который находит +согласованный набор данных во всех четырёх snapshot-таблицах (`stg.airports`, +`stg.airplanes`, `stg.routes`, `stg.seats`) по одному `_load_id`. Если любая +из этих таблиц пуста — batch resolver не найдёт общего батча, и весь ODS pipeline +не запустится. + +Это означает: без заполненного STG невозможна загрузка ODS и всей последующей +цепочки (DDS, DM). Поэтому весь STG реализован как эталон — чтобы пайплайн +работал с первого запуска. + +`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 + +--- + +## Часть 1. ODS-слой (Operational Data Store) + +> **Цель:** Привести данные из STG к целевым типам, очистить, дедуплицировать. +> Справочники (`airplanes`, `seats`) загружаются стратегией TRUNCATE + INSERT +> из последнего согласованного батча STG. (`ods.routes` реализован в эталоне.) +> +> **Аналог для изучения:** `sql/ods/airports_ddl.sql`, `sql/ods/airports_load.sql` + +### 1.1. ods.airplanes + +**Описание:** Очищенный справочник моделей воздушных судов с правильными типами. + +**Источник:** `stg.airplanes` + +| Поле | Тип | Описание | Маппинг из STG | +|------|-----|----------|----------------| +| `airplane_code` | TEXT NOT NULL | Код модели (PK) | `airplane_code` | +| `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_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` | + +**Тип историзации:** Нет (текущее состояние справочника, TRUNCATE + INSERT). + +**Стратегия загрузки:** TRUNCATE + INSERT (полный снимок из STG). + +**Бизнес-правила:** +- Привести `range` и `speed` из TEXT в INTEGER. +- Если `model` хранится в JSON-формате — извлечь русское название (`->>'ru'`). + Изучите, как это сделано для `airports` в эталоне. + +**Тип хранения Greenplum:** Append-Only Row. + +**Distribution Key:** `airplane_code` + +--- + +### 1.2. ods.seats + +**Описание:** Карта посадочных мест с корректными типами. + +**Источник:** `stg.seats` + +| Поле | Тип | Описание | Маппинг из STG | +|------|-----|----------|----------------| +| `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_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` | + +**Тип историзации:** Нет (текущее состояние справочника, TRUNCATE + INSERT). + +**Стратегия загрузки:** TRUNCATE + INSERT. + +**Бизнес-правила:** +- Составной PK: `(airplane_code, seat_no)`. +- Значения `fare_conditions` ограничены: `Economy`, `Business`, `Comfort`. + +**Тип хранения Greenplum:** Append-Only Row. + +**Distribution Key:** `airplane_code` + +--- + +--- + +## Часть 2. DDS-слой (Detailed Data Store) — Измерения + +> **Цель:** Построить измерения звёздной схемы (Star Schema) с суррогатными +> ключами. SCD1-измерения обновляют атрибуты «на месте». SCD2-измерение +> хранит историю изменений через версионирование. +> +> **Аналог для SCD1:** `sql/dds/dim_airports_ddl.sql`, `sql/dds/dim_airports_load.sql` + +### 2.1. dds.dim_airplanes (SCD1) + +**Описание:** Измерение моделей самолётов. Содержит технические характеристики +и рассчитанное общее количество мест (обогащение из `ods.seats`). + +**Источники:** `ods.airplanes` + `ods.seats` + +| Поле | Тип | Описание | Маппинг | +|------|-----|----------|---------| +| `airplane_sk` | INTEGER NOT NULL | Суррогатный ключ | Генерация: `MAX(airplane_sk) + ROW_NUMBER()` | +| `airplane_bk` | TEXT NOT NULL | Бизнес-ключ (код модели) | `ods.airplanes.airplane_code` | +| `model` | TEXT NOT NULL | Название модели | `ods.airplanes.model` | +| `range_km` | INTEGER | Дальность полёта, км | `ods.airplanes.range_km` | +| `speed_kmh` | INTEGER | Скорость, км/ч | `ods.airplanes.speed_kmh` | +| `total_seats` | INTEGER | Общее кол-во мест | `COUNT(ods.seats.*) по airplane_code` | +| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT | +| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE | +| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` | +| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` | + +**Тип историзации:** SCD1 (обновление атрибутов без версионирования). + +**Гранулярность:** Одна строка = одна модель самолёта. + +**Стратегия загрузки:** UPSERT (UPDATE существующих + INSERT новых). +- UPDATE: если атрибуты (`model`, `range_km`, `speed_kmh`, `total_seats`) + изменились (проверка через `IS DISTINCT FROM`). +- INSERT: если `airplane_bk` ещё не существует в `dim_airplanes`. + +**Обогащение:** Поле `total_seats` рассчитывается как количество строк +в `ods.seats` для данного `airplane_code` (LEFT JOIN). + +**Генерация суррогатного ключа:** `MAX(airplane_sk) + ROW_NUMBER()`. +Безопасно при `concurrency=1` в DAG. + +**Distribution Key:** `airplane_sk` + +--- + +### 2.2. dds.dim_passengers (SCD1) + +**Описание:** Измерение пассажиров. Извлекается из таблицы билетов — каждый +уникальный `passenger_id` становится строкой измерения. + +**Источник:** `ods.tickets` + +| Поле | Тип | Описание | Маппинг | +|------|-----|----------|---------| +| `passenger_sk` | INTEGER NOT NULL | Суррогатный ключ | Генерация: `MAX(passenger_sk) + ROW_NUMBER()` | +| `passenger_id` | TEXT NOT NULL | Идентификатор пассажира (BK) | `ods.tickets.passenger_id` | +| `passenger_name` | TEXT NOT NULL | ФИО пассажира | `ods.tickets.passenger_name` | +| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT | +| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE | +| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` | +| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` | + +**Тип историзации:** SCD1. + +**Гранулярность:** Одна строка = один уникальный пассажир. + +**Стратегия загрузки:** UPSERT. +- Из `ods.tickets` один пассажир может встречаться в нескольких билетах. + Необходима дедупликация: берём последнее (актуальное) имя. + **Подсказка:** используйте `ROW_NUMBER() OVER (PARTITION BY passenger_id ORDER BY event_ts DESC NULLS LAST, _load_ts DESC, ticket_no DESC)` + для детерминированного выбора самой свежей записи. +- UPDATE: если `passenger_name` изменилось. +- INSERT: если `passenger_id` ещё не существует. + +**Бизнес-правила:** +- `passenger_id` — бизнес-ключ. Один пассажир может иметь несколько билетов, + но в измерении должна быть ровно одна строка. +- Имя обновляется, если изменилось в последнем билете (SCD1). + +**Distribution Key:** `passenger_sk` + +--- + +### 2.3. dds.dim_routes (SCD2) + +> **Это ключевой вызов курсовой.** Реализация SCD Type 2 — обязательный навык +> для Data Engineer. Ниже — алгоритм текстом; SQL вы пишете самостоятельно. +> Если застряли — сверьтесь с веткой `solution`. + +**Описание:** Измерение авиамаршрутов с полной историей изменений. Если у маршрута +меняется самолёт, аэропорт или расписание — создаётся новая версия, а старая +закрывается. Это позволяет видеть, какой маршрут действовал на момент конкретного +рейса. + +**Источники:** `ods.routes` + `dds.dim_airports` + `dds.dim_airplanes` + +| Поле | Тип | Описание | Маппинг | +|------|-----|----------|---------| +| `route_sk` | INTEGER NOT NULL | Суррогатный ключ | Генерация: `MAX(route_sk) + ROW_NUMBER()` | +| `route_bk` | TEXT NOT NULL | Бизнес-ключ маршрута | `ods.routes.route_no` | +| `departure_airport` | TEXT NOT NULL | Код аэропорта вылета | `ods.routes.departure_airport` | +| `arrival_airport` | TEXT NOT NULL | Код аэропорта прилёта | `ods.routes.arrival_airport` | +| `airplane_code` | TEXT NOT NULL | Код модели самолёта | `ods.routes.airplane_code` | +| `departure_city` | TEXT NOT NULL | Город вылета (денормализация) | `dds.dim_airports.city` по `departure_airport` | +| `arrival_city` | TEXT NOT NULL | Город прилёта (денормализация) | `dds.dim_airports.city` по `arrival_airport` | +| `airplane_model` | TEXT NOT NULL | Модель самолёта (денормализация) | `dds.dim_airplanes.model` | +| `total_seats` | INTEGER NOT NULL | Кол-во мест (денормализация) | `dds.dim_airplanes.total_seats` | +| `days_of_week` | TEXT | Дни недели | `ods.routes.days_of_week` (приведение к TEXT) | +| `departure_time` | TIME | Время вылета | `ods.routes.departure_time` | +| `duration` | INTERVAL | Длительность полёта | `ods.routes.duration` | +| `hashdiff` | TEXT NOT NULL | Хэш версионируемых атрибутов | См. формулу ниже | +| `valid_from` | DATE NOT NULL | Начало действия версии | Первая версия: `'1900-01-01'`; последующие: `CURRENT_DATE` | +| `valid_to` | DATE | Конец действия версии (NULL = текущая) | `NULL` для актуальных, `CURRENT_DATE` при закрытии | +| `created_at` | TIMESTAMP NOT NULL | Дата создания версии | `now()` при INSERT | +| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE | +| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` | +| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` | + +**Тип историзации:** SCD2 (версионирование с полуоткрытым интервалом). + +**Гранулярность:** Одна строка = одна версия маршрута. + +#### Формула hashdiff + +```sql +md5(concat_ws('|', + departure_airport, + arrival_airport, + airplane_code, + days_of_week, + departure_time, + duration +)) +``` + +Хэш считается по атрибутам, изменение которых означает «новую версию маршрута». +Денормализованные поля (`departure_city`, `arrival_city`, `airplane_model`, +`total_seats`) **не входят** в `hashdiff` — они обновляются отдельно (SCD1-refresh), +не создавая новую версию. + +#### Алгоритм SCD2 (пошагово) + +1. **Подготовка:** Рассчитайте `hashdiff` для каждого маршрута из `ods.routes`. +2. **Найдите изменения:** Сравните `hashdiff` текущих версий + (`valid_to IS NULL`) с новыми значениями из ODS. +3. **Закройте устаревшие версии:** Для маршрутов, у которых `hashdiff` изменился, + выполните UPDATE: `valid_to = CURRENT_DATE`, `updated_at = now()`. +4. **Закройте исчезнувшие маршруты:** Если маршрут был в DDS (`valid_to IS NULL`), + но отсутствует в ODS — тоже закройте: `valid_to = CURRENT_DATE`. +5. **Вставьте новые версии:** INSERT строки с `valid_to = NULL` + для изменённых и новых маршрутов. Значение `valid_from` зависит от того, + встречался ли маршрут в DDS ранее: + - **Первая версия** (маршрут ещё не было в DDS): `valid_from = '1900-01-01'`. + Sentinel-дата покрывает всю историю полётов, чтобы point-in-time JOIN + фактов на исторические рейсы находил корректный `route_sk`. + - **Последующие версии** (маршрут уже был): `valid_from = CURRENT_DATE`. +6. **SCD1-обновление денормализованных полей:** Обновите `departure_city`, + `arrival_city`, `airplane_model`, `total_seats` для ВСЕХ открытых версий + (даже если `hashdiff` не менялся). + +**Интервалы версий:** полуоткрытые `[valid_from, valid_to)`. +Текущая (актуальная) версия: `valid_to IS NULL`. + +**Distribution Key:** `route_sk` + +--- + +## Часть 3. DM-слой (Data Marts) — Витрины + +> **Цель:** Построить аналитические витрины поверх DDS. +> Каждая витрина отвечает на конкретный бизнес-вопрос. +> +> **Аналог для изучения:** `sql/dm/sales_report_ddl.sql`, `sql/dm/sales_report_load.sql` + +### 3.1. dm.airport_traffic + +**Бизнес-вопрос:** «Какой пассажиропоток и выручка у каждого аэропорта по дням?» + +**Описание:** Ежедневная статистика по каждому аэропорту: сколько рейсов +вылетело/прилетело, сколько пассажиров, какая выручка. Аэропорт выступает +в двойной роли — и как точка вылета, и как точка прилёта. + +**Источники:** `dds.fact_flight_sales` + `dds.dim_airports` + `dds.dim_calendar` + +| Поле | Тип | Описание | Маппинг | +|------|-----|----------|---------| +| `traffic_date` | DATE NOT NULL | Дата (зерно, часть 1) | `dim_calendar.date_actual` | +| `airport_sk` | INTEGER NOT NULL | Суррогатный ключ аэропорта (зерно, часть 2) | `fact.departure_airport_sk` / `fact.arrival_airport_sk` (UNION ALL) | +| `airport_bk` | TEXT NOT NULL | Код аэропорта (денормализация) | `dim_airports.airport_bk` | +| `city` | TEXT NOT NULL | Город (денормализация) | `dim_airports.city` | +| `departures_flights` | INTEGER NOT NULL | Кол-во рейсов на вылет | `COUNT(DISTINCT flight_id)` роль «departure» | +| `departures_passengers` | INTEGER NOT NULL | Пассажиры на вылет | `SUM(is_boarded)` роль «departure» | +| `departures_revenue` | NUMERIC(15,2) NOT NULL | Выручка по вылетам | `SUM(price)` роль «departure» | +| `arrivals_flights` | INTEGER NOT NULL | Кол-во рейсов на прилёт | `COUNT(DISTINCT flight_id)` роль «arrival» | +| `arrivals_passengers` | INTEGER NOT NULL | Пассажиры на прилёт | `SUM(is_boarded)` роль «arrival» | +| `arrivals_revenue` | NUMERIC(15,2) NOT NULL | Выручка по прилётам | `SUM(price)` роль «arrival» | +| `total_passengers` | INTEGER NOT NULL | Общий пассажиропоток (вылет + прилёт) | `SUM(is_boarded)` обе роли | +| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT | +| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE | +| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` | +| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` | + +**Тип историзации:** Нет (UPSERT — текущее состояние метрик за день). + +**Гранулярность:** Одна строка = один аэропорт за один день. + +**Стратегия загрузки:** Инкрементальный UPSERT с HWM по `fact_flight_sales._load_ts`. + +**Ключевой приём — Unpivot (UNION ALL):** +Каждый факт продажи порождает два «события»: вылет (departure) и прилёт (arrival). +Используйте UNION ALL для разворота факта в два ряда — по `departure_airport_sk` +и `arrival_airport_sk`. Затем сгруппируйте по `(traffic_date, airport_sk)`. + +**Метрики:** +- `departures_flights` — `COUNT(DISTINCT flight_id)` для роли «вылет» +- `departures_passengers` — `SUM(is_boarded)` для роли «вылет» +- `departures_revenue` — `SUM(price)` для роли «вылет» +- Аналогично для прилётов +- `total_passengers` — сумма всех `is_boarded` (обе роли) + +**Важно:** Выручка специально разделена на `departures_revenue` и `arrivals_revenue`. +Суммировать их нельзя — это приведёт к двойному счёту (один билет учитывается и +в аэропорту вылета, и в аэропорту прилёта). + +**Тип хранения Greenplum:** Heap (для UPSERT). + +**Distribution Key:** `airport_sk` + +--- + +### 3.2. dm.route_performance + +**Бизнес-вопрос:** «Какие маршруты самые эффективные? Где высокий load factor, +а где теряем пассажиров?» + +**Описание:** Сводная статистика эффективности каждого маршрута за всё время. +Агрегация идёт по бизнес-ключу маршрута (`route_bk`), чтобы собрать данные +со всех исторических версий (SCD2). + +**Источники:** `dds.fact_flight_sales` + `dds.dim_routes` + `dds.dim_calendar` + +| Поле | Тип | Описание | Маппинг | +|------|-----|----------|---------| +| `route_bk` | TEXT NOT NULL | Бизнес-ключ маршрута (зерно) | `dim_routes.route_bk` (GROUP BY) | +| `route_sk` | INTEGER NOT NULL | SK актуальной версии маршрута | `dim_routes.route_sk` WHERE `valid_to IS NULL` | +| `departure_airport_bk` | TEXT NOT NULL | Код аэропорта вылета (денормализация) | `dim_routes.departure_airport` (актуальная версия) | +| `departure_city` | TEXT NOT NULL | Город вылета (денормализация) | `dim_routes.departure_city` (актуальная версия) | +| `arrival_airport_bk` | TEXT NOT NULL | Код аэропорта прилёта (денормализация) | `dim_routes.arrival_airport` (актуальная версия) | +| `arrival_city` | TEXT NOT NULL | Город прилёта (денормализация) | `dim_routes.arrival_city` (актуальная версия) | +| `airplane_bk` | TEXT NOT NULL | Код модели самолёта (денормализация) | `dim_routes.airplane_code` (актуальная версия) | +| `airplane_model` | TEXT NOT NULL | Модель самолёта (денормализация) | `dim_routes.airplane_model` (актуальная версия) | +| `total_seats` | INTEGER NOT NULL | Кол-во мест в самолёте (денормализация) | `dim_routes.total_seats` (актуальная версия) | +| `total_flights` | INTEGER NOT NULL | Всего рейсов | `COUNT(DISTINCT fact.flight_id)` | +| `total_tickets` | INTEGER NOT NULL | Всего проданных билетов | `COUNT(*)` | +| `total_boarded` | INTEGER NOT NULL | Всего посадок | `SUM(CASE WHEN is_boarded THEN 1 ELSE 0 END)` | +| `total_revenue` | NUMERIC(15,2) NOT NULL | Суммарная выручка | `SUM(fact.price)` | +| `avg_ticket_price` | NUMERIC(10,2) | Средняя цена билета | `total_revenue / NULLIF(total_tickets, 0)` | +| `avg_boarding_rate` | NUMERIC(5,4) NOT NULL | Средняя доля посадок | `AVG(CASE WHEN is_boarded THEN 1 ELSE 0 END)` | +| `avg_load_factor` | NUMERIC(5,4) | Средняя заполняемость кресел | `total_boarded / NULLIF(total_flights * total_seats, 0)` | +| `first_flight_date` | DATE | Дата первого рейса | `MIN(dim_calendar.date_actual)` | +| `last_flight_date` | DATE | Дата последнего рейса | `MAX(dim_calendar.date_actual)` | +| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` | +| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` | + +**Тип историзации:** Нет (Full Rebuild — полная перезагрузка каждый запуск). + +**Гранулярность:** Одна строка = один маршрут (по `route_bk`). + +**Стратегия загрузки:** Full Rebuild (TRUNCATE + INSERT). +Витрина небольшая (~1000 строк) — проще пересоздать, чем вычислять дельту. + +**Обработка SCD2:** Факты связаны с разными версиями маршрута (`route_sk`). +Агрегируйте метрики по `route_bk` (бизнес-ключу), чтобы собрать статистику +со ВСЕХ версий. Денормализованные атрибуты берите из ТЕКУЩЕЙ версии +(`valid_to IS NULL`). + +**Метрики:** +- `avg_ticket_price` — `total_revenue / total_tickets` (защита от деления на 0 + через `NULLIF`) +- `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. + +**Distribution Key:** `route_bk` + +**Служебные поля:** `created_at` / `updated_at` здесь не нужны — при Full Rebuild +все строки пересоздаются. Достаточно `_load_ts`. + +--- + +### 3.3. dm.monthly_overview + +**Бизнес-вопрос:** «Какова помесячная динамика: рейсы, выручка, load factor +в разрезе типов самолётов?» + +**Описание:** Помесячная сводка с точным расчётом средней заполняемости (load factor) +через двухуровневую агрегацию. Разрез — по типу самолёта. + +**Источники:** `dds.fact_flight_sales` + `dds.dim_calendar` + `dds.dim_airplanes` ++ `dds.dim_routes` + +| Поле | Тип | Описание | Маппинг | +|------|-----|----------|---------| +| `year_actual` | INTEGER NOT NULL | Год (зерно, часть 1) | `dim_calendar.year_actual` | +| `month_actual` | INTEGER NOT NULL | Месяц (зерно, часть 2) | `dim_calendar.month_actual` | +| `airplane_sk` | INTEGER NOT NULL | SK типа самолёта (зерно, часть 3) | `fact.airplane_sk` | +| `airplane_bk` | TEXT NOT NULL | Код модели (денормализация) | `dim_airplanes.airplane_bk` | +| `airplane_model` | TEXT NOT NULL | Название модели (денормализация) | `dim_airplanes.model` | +| `total_seats` | INTEGER NOT NULL | Кол-во мест (денормализация) | `dim_airplanes.total_seats` | +| `total_flights` | INTEGER NOT NULL | Кол-во уникальных рейсов | `COUNT(DISTINCT fact.flight_id)` | +| `total_tickets` | INTEGER NOT NULL | Кол-во проданных билетов | `SUM(tickets_sold_per_flight)` (уровень 2) | +| `total_boarded` | INTEGER NOT NULL | Кол-во посадок | `SUM(boarded_per_flight)` (уровень 2) | +| `total_revenue` | NUMERIC(15,2) NOT NULL | Суммарная выручка | `SUM(revenue_per_flight)` (уровень 2) | +| `avg_ticket_price` | NUMERIC(10,2) | Средняя цена билета | `total_revenue / NULLIF(total_tickets, 0)` | +| `avg_load_factor` | NUMERIC(5,4) | Средняя заполняемость кресел | `AVG(flight_load_factor)` (уровень 2) | +| `unique_routes` | INTEGER NOT NULL | Кол-во уникальных маршрутов | `COUNT(DISTINCT dim_routes.route_bk)` | +| `unique_passengers` | INTEGER NOT NULL | Кол-во уникальных пассажиров | `COUNT(DISTINCT fact.passenger_sk)` | +| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT | +| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE | +| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` | +| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` | + +**Тип историзации:** Нет (UPSERT — текущее состояние метрик за месяц). + +**Гранулярность:** Одна строка = один месяц + один тип самолёта. + +**Стратегия загрузки:** Инкрементальный UPSERT с HWM по `fact_flight_sales._load_ts`. +Пересчитываются только затронутые месяцы. + +**Ключевой приём — Двухуровневая агрегация:** + +Чтобы честно посчитать среднюю заполняемость (`avg_load_factor`), нельзя просто +поделить `SUM(boarded)` на `SUM(seats)` — это даёт ошибку (парадокс Симпсона). +Правильный путь: +1. **Уровень 1 (рейс):** Для каждого `flight_id` посчитайте `load_factor = boarded / total_seats`. +2. **Уровень 2 (месяц):** Возьмите `AVG(load_factor)` по всем рейсам месяца. + +**Подсчёт уникальных маршрутов:** Используйте `COUNT(DISTINCT route_bk)` из +`dim_routes` (т.к. `dim_routes` — SCD2, у одного маршрута может быть несколько +`route_sk`). + +**Антипаттерн (Distribution Key):** +Распределение по `(year_actual, month_actual)` — это ошибка. В MPP-системах +распределение по дате ведёт к Data Skew (весь месяц на одном сегменте). +Используйте `airplane_sk`. + +**Тип хранения Greenplum:** Heap (для UPSERT). + +**Distribution Key:** `airplane_sk` + +--- + +### 3.4. dm.passenger_loyalty + +**Бизнес-вопрос:** «Кто наши самые лояльные пассажиры? Сколько они летают, +тратят, какой класс предпочитают?» + +**Описание:** Профиль лояльности каждого пассажира: накопительные метрики +за всю историю перелётов. Самая сложная витрина — требует пересчёта +полной истории для затронутых пассажиров. + +**Источники:** `dds.fact_flight_sales` + `dds.dim_passengers` + `dds.dim_tariffs` ++ `dds.dim_routes` + `dds.dim_calendar` + +| Поле | Тип | Описание | Маппинг | +|------|-----|----------|---------| +| `passenger_sk` | INTEGER NOT NULL | SK пассажира (зерно) | `fact.passenger_sk` | +| `passenger_bk` | TEXT NOT NULL | Идентификатор пассажира (денормализация) | `dim_passengers.passenger_id` | +| `passenger_name` | TEXT NOT NULL | ФИО (денормализация) | `dim_passengers.passenger_name` | +| `total_bookings` | INTEGER NOT NULL | Кол-во бронирований (`book_ref`) | `COUNT(DISTINCT fact.book_ref)` | +| `total_flights` | INTEGER NOT NULL | Кол-во перелётов | `COUNT(*)` | +| `total_boarded` | INTEGER NOT NULL | Кол-во успешных посадок | `SUM(CASE WHEN is_boarded THEN 1 ELSE 0 END)` | +| `total_spent` | NUMERIC(15,2) NOT NULL | Общие траты | `SUM(fact.price)` | +| `avg_ticket_price` | NUMERIC(10,2) | Средняя цена билета | `total_spent / NULLIF(total_flights, 0)` | +| `favorite_fare_conditions` | TEXT | Самый частый класс обслуживания | Мода по `dim_tariffs.fare_conditions` | +| `unique_routes` | INTEGER NOT NULL | Кол-во уникальных маршрутов | `COUNT(DISTINCT dim_routes.route_bk)` | +| `first_flight_date` | DATE | Дата первого перелёта | `MIN(dim_calendar.date_actual)` | +| `last_flight_date` | DATE | Дата последнего перелёта | `MAX(dim_calendar.date_actual)` | +| `days_as_customer` | INTEGER | Стаж клиента (дней между первым и последним) | `last_flight_date - first_flight_date` | +| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT | +| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE | +| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` | +| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` | + +**Тип историзации:** Нет (UPSERT — текущий профиль пассажира). + +**Гранулярность:** Одна строка = один пассажир. + +**Стратегия загрузки:** Инкрементальный UPSERT по «затронутым ключам». + +**Ключевой приём — Метод затронутых ключей:** + +Это не обычный HWM по датам. Алгоритм: +1. Найдите `passenger_sk`, чьи факты изменились (HWM по `fact._load_ts`). +2. Для этих пассажиров **пересчитайте ВСЮ историю** — все их перелёты от начала. +3. UPSERT результаты. + +Почему? Потому что метрики накопительные (`total_spent`, `first_flight_date`). +Нельзя просто добавить дельту — нужен полный пересчёт для корректности. + +**Метрики:** +- `total_bookings` — `COUNT(DISTINCT book_ref)` по всем перелётам пассажира +- `favorite_fare_conditions` — мода (самое частое значение). + **Подсказка:** используйте `DISTINCT ON` с `ORDER BY COUNT(*) DESC`. +- `unique_routes` — `COUNT(DISTINCT route_bk)` (не `route_sk`! т.к. `dim_routes` — SCD2) +- `days_as_customer` — `last_flight_date - first_flight_date` +- `avg_ticket_price` — `total_spent / total_flights` + +**Фильтрация NULL:** В `fact_flight_sales` поле `passenger_sk` может быть NULL +(защитная фильтрация от неконсистентных фактов). Исключите такие строки: +`WHERE passenger_sk IS NOT NULL`. + +**Тип хранения Greenplum:** Heap (для UPSERT). + +**Distribution Key:** `passenger_sk` + +--- + +## Пересчёт факта после реализации измерений + +После того, как вы реализуете все DDS-измерения (`dim_airplanes`, `dim_passengers`, +`dim_routes`), нужно пересчитать факт — он загружался ещё без ваших SK: + +1. Запустите загрузку своих измерений (dim_airplanes, dim_passengers, dim_routes) +2. Выполните `TRUNCATE dds.fact_flight_sales;` +3. Перезапустите загрузку факта (DAG или только таск `load_dds_fact_flight_sales`) +4. Перезапустите DM-витрины + +```sql +-- Проверка: все SK заполнены +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, + COUNT(passenger_sk) AS has_passenger_sk, + COUNT(airplane_sk) AS has_airplane_sk +FROM dds.fact_flight_sales; +``` + +Это стандартная практика при **late-arriving dimensions** (опаздывающих измерениях): +факт загрузился раньше, чем были готовы справочники, поэтому SK заполнены не были. +После пересчёта все SK проставятся корректно. + +--- + +## Валидация + +После реализации каждого слоя запустите валидационный DAG `bookings_validate` +в Airflow UI. Он проверит: + +- Таблицы существуют и содержат данные +- PK не содержат NULL +- SCD2: корректность `valid_from`/`valid_to`, отсутствие «дыр» в версиях +- Кросс-слойная консистентность (ODS vs STG по кол-ву записей) +- DM-витрины содержат данные за загруженные дни + +Сообщения об ошибках укажут, что именно не так и что делать дальше. + +--- + +## Приложение: ER-диаграмма (источник) + +``` +bookings.airplanes_data bookings.seats + airplane_code (PK) airplane_code (FK) ──┐ + model (JSON) seat_no │ + range fare_conditions │ + speed │ + │ │ + └──────────────────────────────────────────────┘ + │ +bookings.routes + route_no (PK, part 1) + validity (PK, part 2) + departure_airport (FK → airports_data) + arrival_airport (FK → airports_data) + airplane_code (FK → airplanes_data) + days_of_week + scheduled_time + duration + +bookings.flights + flight_id (PK) + route_no (FK → routes) + status + scheduled_departure / scheduled_arrival + actual_departure / actual_arrival + +bookings.bookings ─── bookings.tickets ─── bookings.segments + book_ref (PK) ticket_no (PK) ticket_no (FK) + book_date book_ref (FK) flight_id (FK) + total_amount passenger_id fare_conditions + passenger_name price + outbound + +bookings.boarding_passes + ticket_no (FK) + flight_id (FK) + seat_no + boarding_no + boarding_time +``` diff --git a/docs/bookings_to_gp_dds.md b/docs/bookings_to_gp_dds.md new file mode 100644 index 0000000..d2f28b7 --- /dev/null +++ b/docs/bookings_to_gp_dds.md @@ -0,0 +1,180 @@ +# DAG `bookings_to_gp_dds`: `ods` → `dds` в Greenplum + +Этот DAG — учебный пример загрузки аналитического слоя **DDS** (Star Schema) из текущего состояния **ODS**. +Здесь сосредоточены ключевые паттерны аналитического хранилища: SCD1, SCD2 с hashdiff, +point-in-time join, защитные LEFT JOIN для устойчивости к data quality аномалиям. + +## Что делает 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. + +## Что должно быть готово перед запуском + +1) Стенд поднят: + +```bash +make up +``` + +2) STG и ODS уже загружены: + +- выполнены DAG-и `bookings_to_gp_stage` и `bookings_to_gp_ods`; +- DDL-объекты созданы (`bookings_dds_ddl` или `make ddl-gp`). + +## Как запустить + +1) Откройте Airflow UI: http://localhost:8080. +2) Если запускаете DDS впервые — выполните `bookings_dds_ddl`. +3) Запустите `bookings_to_gp_dds`. + +## Граф зависимостей + +``` +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_routes` зависит от `dim_airports` и `dim_airplanes` (денормализация), +а факт ждёт завершения **всех** пяти измерений. + +## Как это работает внутри (по шагам) + +### 1) `load_dds_dim_calendar` → `dq_dds_dim_calendar` + +- **SQL:** `sql/dds/dim_calendar_load.sql`, `sql/dds/dim_calendar_dq.sql` +- **Паттерн:** Full Rebuild — каждый запуск пересоздаёт календарь целиком. + Измерение маленькое и детерминированное, дельту считать нет смысла. + +### 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, имя, контакты) | + +Паттерн загрузки — SCD1 UPSERT: TEMP TABLE → UPDATE (IS DISTINCT FROM) → INSERT. + +### 6) `load_dds_dim_routes` → `dq_dds_dim_routes` (SCD2) + +- **SQL:** `sql/dds/dim_routes_load.sql`, `sql/dds/dim_routes_dq.sql` +- **Паттерн:** SCD2 — самый нетривиальный паттерн в проекте. Работает в 3 фазы: + +**Фаза 1. Hashdiff и закрытие старых версий.** +Скрипт считает MD5-хеш от шести бизнес-атрибутов маршрута (`departure_airport`, `arrival_airport`, +`airplane_code`, `days_of_week`, `departure_time`, `duration`). +Если хеш текущей версии в DDS не совпадает с хешем из ODS — старая версия закрывается +(`valid_to = CURRENT_DATE`). Также закрываются маршруты, исчезнувшие из ODS. + +**Фаза 2. Вставка новых версий.** +Для изменённых и совершенно новых маршрутов создаётся новая строка. +`valid_from` выставляется в `1900-01-01` для первой версии маршрута и `CURRENT_DATE` для версии 2+. +Суррогатный ключ (`route_sk`) генерируется через `MAX(route_sk) + ROW_NUMBER()`. + +> **Важно:** такая генерация SK безопасна только при `max_active_runs=1` (Airflow гарантирует +> последовательный запуск). В боевых системах используют sequence. + +**Фаза 3. Обновление денормализованных атрибутов.** +`dim_routes` хранит денормализованные SCD1-атрибуты из `dim_airports` (города) +и `dim_airplanes` (модель, кол-во мест). Если, например, город переименовали — +фаза 3 обновляет **все** версии маршрута (и текущие, и исторические), +при этом `_load_id` и `_load_ts` не перезаписываются (lineage версий сохраняется). + +### 7) `load_dds_fact_flight_sales` → `dq_dds_fact_flight_sales` + +- **SQL:** `sql/dds/fact_flight_sales_load.sql`, `sql/dds/fact_flight_sales_dq.sql` +- **Зерно:** `(ticket_no, flight_id)` — один билет на один рейс. +- **Паттерн:** инкрементальный UPSERT. + +Три учебных приёма в этом скрипте: + +**Защитные LEFT JOIN (defensive coding).** +Все JOIN-ы с измерениями — `LEFT JOIN`. 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. + +**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 +``` + +```sql +SELECT COUNT(*) FROM dds.dim_calendar; +SELECT COUNT(*) FROM dds.dim_routes; +SELECT COUNT(*) FROM dds.fact_flight_sales; + +-- Проверка: кол-во строк факта ≈ кол-во строк ODS segments +SELECT + (SELECT COUNT(*) FROM dds.fact_flight_sales) AS fact_rows, + (SELECT COUNT(*) FROM ods.segments) AS ods_rows; + +-- Проверка 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; +``` + +Ожидаемо: `fact_rows = ods_rows`, `current_versions ≤ total_versions`. + +## Типичные ошибки + +- `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 завершился успешно без пропуска задач. diff --git a/docs/bookings_to_gp_dm.md b/docs/bookings_to_gp_dm.md new file mode 100644 index 0000000..d635709 --- /dev/null +++ b/docs/bookings_to_gp_dm.md @@ -0,0 +1,171 @@ +# DAG `bookings_to_gp_dm`: `dds` → `dm` в Greenplum + +Этот DAG — учебный пример загрузки слоя **DM** (Data Mart / витрины) из текущего состояния **DDS**. +Все 5 витрин загружаются **параллельно** и демонстрируют разные стратегии загрузки — +это ключевая учебная ценность данного DAG. + +## Что делает DAG + +Загружает 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 по месяцам) | + +## Что должно быть готово перед запуском + +1) Стенд поднят: + +```bash +make up +``` + +2) STG, ODS и DDS уже загружены: + +- выполнены DAG-и `bookings_to_gp_stage`, `bookings_to_gp_ods`, `bookings_to_gp_dds`; +- DDL-объекты созданы (`bookings_dm_ddl` или `make ddl-gp`). + +## Как запустить + +1) Откройте Airflow UI: http://localhost:8080. +2) Если запускаете DM впервые — выполните `bookings_dm_ddl`. +3) Запустите `bookings_to_gp_dm`. + +## Граф зависимостей + +``` +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 ─┘ +``` + +Все 5 веток полностью независимы и работают параллельно. + +## Как это работает внутри (по шагам) + +### 1) `load_dm_sales_report` → `dq_dm_sales_report` + +- **SQL:** `sql/dm/sales_report_load.sql`, `sql/dm/sales_report_dq.sql` +- **Паттерн:** инкрементальный UPSERT (HWM по датам). + +Витрина агрегирует продажи билетов по дате, аэропортам вылета/прилёта и тарифу. +Инкрементальность работает через 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 +``` + +```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; + +-- Нет дублей по бизнес-ключу route_performance +SELECT route_bk, COUNT(*) FROM dm.route_performance GROUP BY route_bk HAVING COUNT(*) > 1; +``` + +Ожидаемо: все витрины непусты, инварианты соблюдены, дублей нет. + +## Типичные ошибки + +- `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 задачу с ошибкой и исправьте. diff --git a/docs/bookings_to_gp_ods.md b/docs/bookings_to_gp_ods.md new file mode 100644 index 0000000..3ecc718 --- /dev/null +++ b/docs/bookings_to_gp_ods.md @@ -0,0 +1,176 @@ +# DAG `bookings_to_gp_ods`: `stg` → `ods` в Greenplum + +Этот DAG — учебный пример загрузки типизированного слоя **ODS** из уже подготовленного слоя **STG**. +Логика каноничная: **TRUNCATE + INSERT** для snapshot-справочников, **SCD1 UPSERT** для транзакционных +таблиц + DQ-проверки. + +## Что делает 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`). + +## Что должно быть готово перед запуском + +1) Стек поднят: + +```bash +make up +``` + +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` в конфиге запуска: + +```json +{"stg_batch_id": "manual__2026-02-22T12:00:00+00:00"} +``` + +Если конфиг не передан, DAG автоматически возьмёт последний согласованный snapshot-батч. + +## Граф зависимостей + +``` +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 ◀──────────┘ +``` + +Ветка `seats` работает параллельно с веткой `routes → flights → segments → boarding_passes`. +Обе ветки сходятся на `finish_ods_summary`. + +## Как это работает внутри (по шагам) + +### 1) `resolve_stg_batch_id` (Python) + +Определяет, какой STG-батч использовать для snapshot-справочников. +Если `stg_batch_id` не передан через `dag_run.conf`, ищет последний **согласованный** батч — +`_load_id`, который есть одновременно во всех четырёх snapshot-таблицах +(`stg.airports`, `stg.airplanes`, `stg.routes`, `stg.seats`). +Для этого используется **INTERSECT** по `_load_id`. + +> **Зачем согласованность?** Чтобы ODS загружал только те данные, для которых приехали +> ВСЕ связанные справочники. Иначе возможна потеря ссылочной целостности при сборке витрин. + +### 2–10) Загрузка 9 таблиц: `load_ods_*` → `dq_ods_*` + +Каждая пара задач работает одинаково: + +| # | Задача | 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 (инкремент) | + +### Два паттерна загрузки + +В ODS используются **два разных паттерна** — выбор зависит от типа данных и формата хранения: + +**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 +``` + +```sql +SELECT COUNT(*) FROM ods.bookings; +SELECT COUNT(*) FROM ods.tickets; +SELECT COUNT(*) FROM ods.flights; + +-- Проверка: в ODS не должно быть дублей по бизнес-ключу +SELECT book_ref, COUNT(*) +FROM ods.bookings +GROUP BY 1 +HAVING COUNT(*) > 1; +``` + +Ожидаемо: в последнем запросе `0` строк. + +## Типичные ошибки + +- `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 задач. diff --git a/docs/bookings_to_gp_stage.md b/docs/bookings_to_gp_stage.md index dac1a63..9cbb3e2 100644 --- a/docs/bookings_to_gp_stage.md +++ b/docs/bookings_to_gp_stage.md @@ -2,7 +2,7 @@ Этот DAG — основной учебный пример в стенде. Он показывает путь данных из источника **Postgres** (`bookings-db`, демо‑БД `demo`) в сырой слой **STG** в **Greenplum** с инкрементальной загрузкой -и простой проверкой качества данных. +и простыми проверками качества данных. ## Что делает DAG @@ -10,6 +10,10 @@ (генератор всегда “шагает” вперёд от `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. ## Что должно быть готово перед запуском @@ -25,9 +29,9 @@ make up make bookings-init ``` -Важно: генератор `bookings` в этом стенде поддерживается только в режиме `BOOKINGS_JOBS=1`. - -3) В Greenplum созданы `stg.bookings_ext` и `stg.bookings` (выберите один вариант): +3) В Greenplum созданы STG‑объекты (внешние `*_ext` через PXF и внутренние таблицы слоя `stg`) +для всех таблиц потока: `bookings`, `tickets`, `airports`, `airplanes`, `routes`, `seats`, `flights`, +`segments`, `boarding_passes` (выберите один вариант): - учебный вариант: запустить DAG `bookings_stg_ddl` в Airflow UI; - технический шорткат: `make ddl-gp`. @@ -59,19 +63,64 @@ make bookings-init - выполняет `sql/stg/bookings_load.sql` в Greenplum; - берёт строки из `stg.bookings_ext`, которые попадают в новое окно инкремента; - вставляет их в `stg.bookings`, добавляя тех.колонки: - - `src_created_at_ts` (опорная метка времени для инкремента), - - `load_dttm`, - - `batch_id={{ run_id }}`. + - `event_ts` (опорная метка времени для инкремента), + - `_load_ts`, + - `_load_id={{ run_id }}`. 3) `check_row_counts` - выполняет `sql/stg/bookings_dq.sql` в Greenplum; - считает количество строк в источнике за то же окно инкремента и сравнивает с количеством строк, - вставленных в `stg.bookings` для текущего `batch_id`; + вставленных в `stg.bookings` для текущего `_load_id`; - при расхождении делает `RAISE EXCEPTION` с понятным текстом. -4) `finish_summary` +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`); - логирует краткую сводку в конце запуска. ## Как проверить результат @@ -86,17 +135,22 @@ make gp-psql SELECT COUNT(*) FROM stg.bookings; SELECT - src_created_at_ts, - load_dttm, - batch_id + event_ts, + _load_ts, + _load_id FROM stg.bookings -ORDER BY src_created_at_ts DESC +ORDER BY event_ts DESC LIMIT 10; ``` ## Типичные ошибки - `database "demo" does not exist`: демо‑БД не установлена → выполните `make bookings-init`. -- Ошибки про `stg.bookings_ext`/`stg.bookings`: не применён DDL → запустите `bookings_stg_ddl` или `make ddl-gp`. +- Ошибки про `stg.*`/`stg.*_ext`: не применён DDL → запустите `bookings_stg_ddl` или `make ddl-gp`. - Ошибки PXF (`protocol "pxf" does not exist`, connection refused): перезапустите `greenplum` и повторите DDL. - Для технических деталей см. `docs/internal/pxf_bookings.md`. + Для технических деталей см. `docs/reference/pxf_bookings.md`. + +## Рекомендации по качеству решения + +Ревью решения и список улучшений, которые делают пайплайн более “эталонным” для обучения: +`docs/archive/bookings_stg_code_review.md`. diff --git a/docs/dag_execution_order.md b/docs/dag_execution_order.md new file mode 100644 index 0000000..575a8a5 --- /dev/null +++ b/docs/dag_execution_order.md @@ -0,0 +1,35 @@ +# Порядок запуска DAG (Cross-DAG Dependencies) + +В этом стенде пайплайны разделены на несколько DAG-ов по слоям DWH (STG, ODS, DDS, DM). +Они настроены с `schedule=None`, так как это учебный проект. + +Чтобы данные корректно прошли от источника до витрин, запускать 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. + +*(Технический шорткат: `make ddl-gp` применяет DDL для всех 4 слоёв сразу).* + +## 2. Ежедневная загрузка (ETL) + +Для прогрузки новой порции данных (или полного перерасчёта) соблюдайте следующую цепочку: + +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` или полный перерасчёт). + +> **💡 Архитектурная заметка:** В реальном production-окружении (Airflow) эти связи между DAG-ами обычно настраиваются автоматически через `TriggerDagRunOperator`, `ExternalTaskSensor` или механизмы Data-Aware Scheduling (Datasets/Data Assets). В учебных целях мы оставили их ручными, чтобы вы могли проинспектировать каждый слой после его загрузки. diff --git a/docs/design/PRD.md b/docs/design/PRD.md new file mode 100644 index 0000000..3a84e70 --- /dev/null +++ b/docs/design/PRD.md @@ -0,0 +1,244 @@ +# PRD: Greenplum Bookings DWH + +> Курсовая работа для курса [DE Roadmap](https://github.com/dementev-dev/de-roadmap). +> Статус: **ЧЕРНОВИК v0.1** | Дата: 2026-03-08 + +--- + +## 1. Видение продукта + +**Greenplum Bookings DWH** — учебный стенд, на котором студент самостоятельно строит +end-to-end ETL-пайплайн: от базы-источника до аналитических витрин. + +Стенд имитирует реальную рабочую задачу Data-инженера: +- Есть «боевая» система-источник (bookings-db), в которой каждый день появляются + новые данные — как в жизни, без ограниченного объёма. +- Есть DWH на Greenplum с классическими слоями (STG → ODS → DDS → DM). +- Есть Airflow, оркестрирующий загрузку. +- Есть ТЗ от «аналитика» с описанием ожидаемых таблиц и маппингов. + +Студент получает **частично реализованный пайплайн** (эталонный вертикальный срез) +и **дореализует остальное** по ТЗ — SQL-скрипты и таски в DAG. + +### Почему именно bookings? + +Домен бронирования авиабилетов выбран не ради предметной области, а благодаря +генератору данных: каждый вызов `make bookings-generate-day` создаёт новый день +с реалистичным объёмом. Это даёт бесконечный поток инкрементальных данных — +как в настоящей production-системе. + +--- + +## 2. Целевая аудитория и пререквизиты + +**Кто:** студенты курса DE Roadmap, дошедшие до раздела «Курсовая работа». + +**Что уже умеют** (к моменту старта): +- Git: ветки, PR, merge, GitFlow +- SQL: JOIN, CTE, оконные функции, планы запросов, моделирование (3NF, звезда, SCD) +- Python: скрипты, pandas, базовое ООП +- Docker: запуск контейнеров, логи, docker-compose +- Airflow: понятие DAG, операторы, зависимости, UI, логи +- Greenplum: распределение по сегментам, skew, EXPLAIN, отличие от Postgres + +**Уровень:** уверенный джун, готовящийся к первым собеседованиям. + +--- + +## 3. Учебные результаты (Learning Outcomes) + +После выполнения курсовой студент умеет: + +1. **Проектировать и реализовывать ETL-пайплайн** по слоям DWH + (STG → ODS → DDS → DM) на реальном стеке Airflow + Greenplum. +2. **Читать ТЗ от аналитика** (маппинги, описания таблиц) и превращать его + в работающий SQL + DAG. +3. **Писать идемпотентные загрузки** с инкрементальностью (HWM, _load_id, + delete+insert), понимая, почему в Greenplum не используется MERGE. +4. **Реализовывать SCD1/SCD2** и объяснять, когда что применяется. +5. **Настраивать и проверять Data Quality** — понимает, зачем DQ-проверки + и как их встроить в пайплайн. +6. **Работать с Greenplum** как с MPP: выбирать distribution key, + понимать heap vs AO, читать планы запросов. +7. **Оформить проект как портфолио** — репозиторий пригоден для упаковки + в резюме как реальный опыт работы с Airflow и Greenplum. + +--- + +## 4. Скоуп + +### В скоупе (In Scope) + +| Компонент | Описание | +|-----------------------|-------------------------------------------------------------| +| Источник данных | bookings-db (Postgres) с генератором дней | +| DWH | Greenplum, 4 слоя: STG, ODS, DDS, DM | +| Оркестрация | Apache Airflow (PostgresOperator + SQL-файлы) | +| Федеративный доступ | PXF (чтение из Postgres в Greenplum) | +| Инфраструктура | Docker Compose (полный стенд в одной команде) | +| Data Quality | DQ-проверки, встроенные в DAG | +| Документация | README, ТЗ, design docs, naming conventions | + +### Вне скоупа (Out of Scope) + +| Что | Почему | +|-----------------------|-------------------------------------------------------------| +| Kafka / стриминг | Отдельный стенд в курсе | +| BI-инструменты | Фокус на ETL, не на визуализации | +| CI/CD | Избыточно для курсовой | +| Spark / Trino / dbt | Отдельные стенды в курсе | +| Второй источник | Усложнение без пропорциональной учебной ценности | +| CSV-пайплайн | Вынести в [airflow-manual](https://github.com/dementev-dev/airflow-manual) | +| Облачная инфраструктура | Всё локально, через Docker | + +--- + +## 5. Архитектура стенда + +### Сервисы (Docker Compose) + +``` +bookings-db (Postgres 16) ──PXF──> Greenplum 6.27 + ├── stg.* (стейджинг) + ├── ods.* (операционное хранилище) + ├── dds.* (детальное хранилище) + └── dm.* (витрины) + +pgmeta (Postgres 16) ─────────────> Airflow (webserver + scheduler) +``` + +### Слои DWH + +| Слой | Назначение | Паттерн загрузки | Кол-во таблиц | +|------|-----------------------------------|---------------------------|---------------| +| STG | Зеркало источника | TRUNCATE + INSERT (batch) | 9 | +| ODS | Нормализованное хранилище | SCD1 UPSERT | 9 | +| DDS | Измерения + факты (Kimball) | SCD1/SCD2 + fact load | 7 (6D + 1F) | +| DM | Аналитические витрины | HWM-инкремент | 5 | + +### Сущности + +| STG / ODS | DDS | DM | +|----------------------|--------------------------|-----------------------| +| bookings | dim_airports | airport_traffic | +| tickets | dim_airplanes | monthly_overview | +| airports | dim_passengers | passenger_loyalty | +| airplanes | dim_routes (SCD2) | route_performance | +| routes | dim_calendar | sales_report | +| seats | dim_tariffs | | +| flights | fact_flight_sales | | +| segments | | | +| boarding_passes | | | + +--- + +## 6. Педагогическая модель + +Подробности — в [assignment_design.md](assignment_design.md). + +### Принцип: «Эталонный срез + ТЗ» + +Студент получает репозиторий, в котором: + +1. **Эталонный вертикальный срез** — полностью реализованная цепочка + `sales_report` и все её источники вниз по слоям (STG → ODS → DDS → DM). +2. **ТЗ от аналитика** — описание остальных таблиц + (analyst_spec.md — будет создан на Этапе 3, см. TODO.md). +3. **Частично готовый DAG** — студент добавляет свои таски по аналогии. +4. **Валидационный DAG** — студент запускает для самоконтроля. + +### Что делает студент + +- Пишет DDL, SQL-загрузки, DQ-проверки для назначенных таблиц +- Добавляет таски в существующий DAG +- Проверяет результат через валидационный DAG и запросы в Greenplum + +### Что студент НЕ делает + +- Не поднимает инфраструктуру с нуля (Docker Compose дан) +- Не пишет DAG с нуля (шаблон дан) +- Не настраивает Airflow Connections (преднастроены) +- Не работает с PXF-конфигурацией (настроен) + +--- + +## 7. Ветки и workflow + +``` +main (стартовое состояние) + ├── Эталонный срез: реализованные таблицы + DAG + ├── ТЗ от аналитика + ├── Инфраструктура (Docker, Make, PXF) + ├── Заглушки / TODO-маркеры для студенческих заданий + └── Валидационный DAG для самоконтроля + +solution (полное решение) + └── Все таблицы реализованы — эталон для самопроверки + и подсказка, если студент застрял +``` + +### Workflow студента + +1. Форкает репозиторий +2. Читает README и ТЗ +3. `make up` — поднимает стенд +4. `make bookings-init` — инициализирует источник +5. Запускает DDL-DAG'и (эталонные таблицы создаются) +6. Запускает ETL-DAG'и — эталонный срез работает +7. Реализует задания из ТЗ (SQL + таски в DAG) +8. Проверяет себя через валидационный DAG +9. `make bookings-generate-day` — генерирует новый день, проверяет + инкрементальность +10. Защищает работу перед ментором + +--- + +## 8. Критерии приёмки курсовой + +### Для студента (самопроверка) + +- [ ] Стенд поднимается (`make up`) без ошибок +- [ ] Все DAG'и проходят без failed-тасков +- [ ] Данные доезжают от STG до DM +- [ ] Валидационный DAG проходит на всех реализованных таблицах +- [ ] После `make bookings-generate-day` + повторного запуска DAG + данные корректно доливаются (инкрементальность работает) + +### Для ментора (ревью + защита) + +- [ ] Код соответствует naming conventions (`docs/design/naming_conventions.md`) +- [ ] SQL идемпотентен (повторный запуск не ломает данные) +- [ ] Distribution keys выбраны осмысленно +- [ ] Студент может объяснить: почему delete+insert, а не MERGE; + разницу SCD1/SCD2; что такое HWM; как работает _load_id +- [ ] Код оформлен для портфолио (чистый Git-history, README) + +--- + +## 9. Ограничения и риски + +| Риск / ограничение | Митигация | +|--------------------------------------------|-------------------------------------------------| +| Стенд тяжёлый (~8-16 GB RAM) | Указать минимальные требования; не утяжелять | +| bookings-db генерирует данные медленно | Не добавлять нагрузку; задокументировать ожидание| +| Студент может застрять надолго | Ветка `solution` как подсказка; еженедельные встречи | +| Greenplum 6.x — устаревающая версия | Для учебных целей достаточно; паттерны переносимы | +| PXF нестабилен при холодном старте | Задокументировано в README; healthcheck настроен | + +### Требования к машине студента + +- 2-4 CPU, 8-16 GB RAM, 25-40 GB диска +- Linux / WSL2 / macOS +- Docker + Docker Compose + +--- + +## 10. План работ + +План с чекбоксами и рекомендациями по инструментам: [TODO.md](../../TODO.md). + +--- + +## 11. Открытые вопросы + +1. **Название** — рабочее: «Greenplum Bookings DWH». Финализировать. diff --git a/docs/design/assignment_design.md b/docs/design/assignment_design.md new file mode 100644 index 0000000..13d18db --- /dev/null +++ b/docs/design/assignment_design.md @@ -0,0 +1,173 @@ +# Дизайн курсового задания + +> Тактические решения по нарезке задания, порядку выполнения и самопроверке. +> Стратегию и контекст см. в [PRD.md](PRD.md). + +--- + +## 1. Эталонный срез: витрина `sales_report` + +Эталоном выбрана витрина `dm.sales_report` и вся её цепочка вниз по слоям. + +**Почему `sales_report`:** +- Покрывает SCD1 (airports, tariffs), HWM-инкремент, fact load +- Богатая денормализация — хороший образец для подражания +- Средняя сложность — не пугает, но и не тривиальна + +### Эталонные таблицы (даны студенту) + +| Слой | Таблицы | +|------|-----------------------------------------------------------------| +| DM | `sales_report` | +| DDS | `fact_flight_sales`, `dim_airports` (SCD1), `dim_tariffs` (SCD1), `dim_calendar` | +| ODS | `bookings`, `tickets`, `segments`, `flights`, `boarding_passes`, `airports`, `routes` | +| STG | весь слой: `bookings`, `tickets`, `segments`, `flights`, `boarding_passes`, `airports`, `airplanes`, `seats`, `routes` | + +### Задание студенту + +| Слой | Таблицы | Что нового для студента | +|------|-------------------------------------------------------------------|--------------------------------------------------| +| ODS | `airplanes`, `seats` | Практика TRUNCATE+INSERT по аналогии с эталоном | +| DDS | `dim_airplanes` (SCD1), `dim_passengers` (SCD1), `dim_routes` (SCD2) | **SCD2 — ключевой вызов курсовой** | +| DM | `airport_traffic`, `monthly_overview`, `route_performance`, `passenger_loyalty` | Разная сложность (от простой к сложной) | + +--- + +## 2. Рекомендуемый порядок выполнения + +Студенту рекомендуется (но не обязательно) двигаться в таком порядке: + +1. **ODS** (airplanes, seats) — практика TRUNCATE+INSERT +2. **DDS** dim_airplanes, dim_passengers (SCD1) — новые измерения +3. **DDS** dim_routes (**SCD2**) — ключевой вызов +4. **DM** airport_traffic — простая витрина, похожа на sales_report +5. **DM** route_performance — TRUNCATE+INSERT, SCD2-агрегация по BK +6. **DM** monthly_overview — двухуровневая агрегация +7. **DM** passenger_loyalty — самая сложная, пересчёт истории + +Порядок выстроен от простого к сложному. Каждый шаг опирается на опыт +предыдущего. + +--- + +## 3. SCD2: подход «рецепт без готового SQL» + +Реализация `dim_routes` (SCD2) — ключевой вызов курсовой. Студент делает это +самостоятельно, но ТЗ содержит пошаговую подсказку: + +1. Алгоритм SCD2 текстом (без SQL): + - Вычисли `hashdiff` по набору атрибутов (атрибуты перечислены в ТЗ) + - Найди строки, у которых `hashdiff` изменился + - Закрой старую версию (`valid_to = текущая_дата`) + - Вставь новую версию (`valid_from = текущая_дата`, `valid_to = NULL`) +2. Формула hashdiff: `md5(concat_ws('|', field1, field2, ...))` +3. Ссылка на `naming_conventions.md` (поля `valid_from`, `valid_to`, `hashdiff`) +4. Напоминание: полуоткрытый интервал `[valid_from, valid_to)` +5. Если застрял — ветка `solution` + +Самостоятельная реализация — ключ к запоминанию. SCD2 — обязательный вопрос +на собеседованиях DE, и студент должен уметь объяснить его на основе +собственного опыта. + +--- + +## 4. Валидационный DAG (`bookings_validate`) + +Отдельный DAG для самопроверки студента. Запускается вручную в Airflow UI +после реализации заданий. Таски сгруппированы по слоям — студент видит, +где именно проблема. Дополнительный бонус — практика чтения логов Airflow. + +### Примерная структура тасков + +``` +bookings_validate +├── validate_ods +│ ├── check_ods_airplanes_rowcount (ODS >= STG по кол-ву уникальных BK) +│ ├── check_ods_seats_rowcount +│ └── check_ods_no_null_pks (PK not null) +├── validate_dds +│ ├── check_dim_airplanes_exists +│ ├── check_dim_passengers_exists +│ ├── check_dim_routes_scd2 (valid_from/valid_to корректны) +│ └── check_dim_routes_no_gaps (нет «дыр» в версиях SCD2) +└── validate_dm + ├── check_airport_traffic_exists + ├── check_monthly_overview_exists + ├── check_route_performance_exists + └── check_passenger_loyalty_exists +``` + +### Реализация + +- `PostgresOperator` + SQL-скрипты в `sql/validate/` +- Каждый SQL-скрипт выполняет SELECT и бросает исключение (через + `DO $$ ... RAISE EXCEPTION ... $$`), если проверка не пройдена +- Сообщения об ошибках — дружелюбные, с подсказкой что делать дальше + +### Ключевые проверки + +- Таблицы существуют и содержат данные +- PK не содержат NULL +- SCD2: `valid_to IS NULL` для текущих версий, нет перекрытий интервалов +- Кросс-слойная консистентность (row count ODS vs STG) +- DM-витрины содержат данные за загруженные дни + +### Активная проверка SCD2 (`check_dim_routes_scd2`) + +Справочник `bookings.routes` в демо-базе статичен — маршруты не меняются +между запусками генератора. Поэтому при обычном прогоне пайплайна студент +никогда не увидит, как SCD2 закрывает старую версию и создаёт новую. + +Чтобы проверить корректность реализации, таск `check_dim_routes_scd2` +должен быть **активным** (не только читать, но и тестировать загрузку): + +1. Сохранить текущее состояние `ods.routes` и `dds.dim_routes` (temp-таблицы). +2. Вставить в `ods.routes` тестовый маршрут с изменённым атрибутом + (например, `scheduled_time` → `departure_time` сдвинут на 1 час). +3. Вызвать студенческий SQL загрузки `dim_routes` (`sql/dds/dim_routes_load.sql`). +4. Проверить результат: + - Старая версия маршрута закрыта (`valid_to IS NOT NULL`). + - Новая версия открыта (`valid_to IS NULL`, `hashdiff` отличается). + - Нет «дыр» между `valid_to` старой и `valid_from` новой версии. +5. Откатить изменения: восстановить `ods.routes` и `dds.dim_routes` + из сохранённых temp-таблиц. + +Это единственный способ гарантировать, что SCD2 работает, без мутации +источника (что сломало бы генератор `continue()`). + +--- + +## 5. Формат ТЗ от аналитика + +Файл: `docs/assignment/analyst_spec.md` (или несколько файлов по слоям). + +Для каждой таблицы-задания документ содержит: + +- **Имя таблицы** и целевая схема (stg / ods / dds / dm) +- **Описание** — что хранит таблица, бизнес-смысл +- **Список полей** с типами и описанием +- **Маппинг источников** — откуда берётся каждое поле +- **Бизнес-правила и фильтры** (если есть) +- **Тип историзации** (SCD1 / SCD2 / snapshot / append) +- **Гранулярность** (одна строка = ?) +- **Distribution key** (подсказка или задание на выбор) + +Формат — приближен к реальным ТЗ, которые студент встретит на работе. + +--- + +## 6. Бэклог: идеи для будущих итераций + +### PXF-практикум (замена STG-заданий) + +После переноса всего STG-слоя в эталон (см. `docs/plans/2026-03-11_routes-to-reference.md`) +студенты не практикуются в создании PXF external tables и загрузке сырых данных. +Это важный навык для DE — нужно компенсировать отдельным заданием. + +Варианты: +- **Новый источник данных**: подключить CSV/JSON-файл (например, справочник городов + или курсов валют) через PXF и загрузить в отдельную STG-таблицу +- **Мини-задание «подключи внешний справочник»**: студент создаёт PXF external table + для существующей таблицы источника и сравнивает результат с эталонной STG +- **Лабораторная работа**: отдельное упражнение на создание external table + + сравнение форматов (TEXT vs CUSTOM), профилей PXF, обработку типов diff --git a/docs/design/bookings_dds_design.md b/docs/design/bookings_dds_design.md new file mode 100644 index 0000000..985e5b9 --- /dev/null +++ b/docs/design/bookings_dds_design.md @@ -0,0 +1,1063 @@ +# DDS Layer: план реализации Star Schema для bookings + +## Контекст + +STG (9 таблиц, TEXT, append-only) и ODS (9 таблиц, типизированные, SCD1) уже реализованы. +Этот план фиксирует реализацию DDS-слоя: Star Schema с измерениями и таблицей фактов. + +Формат плана аналогичен `docs/design/bookings_ods_design.md` — достаточно детальный, +чтобы реализация была однозначной. + +--- + +## 1) Принятые архитектурные решения + +| Решение | Выбор | Обоснование | +|---------|-------|-------------| +| Схема БД | Единая `dds` (`dds.dim_*`, `dds.fact_*`) | Проще для студентов, один CREATE SCHEMA | +| Суррогатные ключи | UPSERT + `MAX(sk) + ROW_NUMBER()` | Стабильные SK, Greenplum не поддерживает SERIAL | +| SCD2 | `dim_routes` с hashdiff | Реальная история в данных, классический SCD2 паттерн | +| Остальные измерения | SCD1 UPSERT | Стабильные SK для инкрементального факта | +| Загрузка факта | Инкрементальный UPSERT по `(ticket_no, flight_id)` | Консистентно с ODS, учебная ценность | +| `_load_id` в DDS | `{{ run_id }}` (Airflow run_id) | Не привязан к stg_batch_id, DDS читает current state ODS | + +--- + +## 2) Что создаём + +### Измерения (6 штук) + +| Таблица | Бизнес-ключ | SK | Тип | Источник ODS | +|---------|-------------|-----|-----|-------------| +| `dds.dim_calendar` | `date_actual` | `calendar_sk` | Статическая (generate_series) | — | +| `dds.dim_airports` | `airport_code` → `airport_bk` | `airport_sk` | SCD1 UPSERT | `ods.airports` | +| `dds.dim_airplanes` | `airplane_code` → `airplane_bk` | `airplane_sk` | SCD1 UPSERT | `ods.airplanes` + `ods.seats` (total_seats) | +| `dds.dim_tariffs` | `fare_conditions` | `tariff_sk` | SCD1 UPSERT | `ods.segments` (DISTINCT) | +| `dds.dim_passengers` | `passenger_id` → `passenger_bk` | `passenger_sk` | SCD1 UPSERT | `ods.tickets` (дедупликация по passenger_id) | +| `dds.dim_routes` | `route_no` → `route_bk` | `route_sk` | **SCD2** (hashdiff) | `ods.routes` (последняя версия по validity) | + +### Факт (1 штука) + +| Таблица | Зерно | FK на измерения | +|---------|-------|-----------------| +| `dds.fact_flight_sales` | `(ticket_no, flight_id)` — 1 сегмент билета | `calendar_sk`, `departure_airport_sk`, `arrival_airport_sk`, `airplane_sk`, `tariff_sk`, `passenger_sk`, `route_sk` | + +--- + +## 3) DDL таблиц + +### 3.1. dds.dim_calendar +```sql +calendar_sk INTEGER NOT NULL +date_actual DATE NOT NULL +year_actual INTEGER NOT NULL +month_actual INTEGER NOT NULL +day_actual INTEGER NOT NULL +day_of_week INTEGER NOT NULL -- 1=Пн .. 7=Вс (ISO) +day_name TEXT NOT NULL -- Monday, Tuesday, ... +is_weekend BOOLEAN NOT NULL +DISTRIBUTED BY (calendar_sk) +``` +Статическая, заполняется один раз (2016-01-01 .. 2030-12-31). Без `_load_id`/`_load_ts`. + +### 3.2. dds.dim_airports +```sql +airport_sk INTEGER NOT NULL +airport_bk TEXT NOT NULL -- airport_code +airport_name TEXT NOT NULL +city TEXT NOT NULL +country TEXT NOT NULL +timezone TEXT NOT NULL +coordinates TEXT +created_at TIMESTAMP NOT NULL DEFAULT now() +updated_at TIMESTAMP NOT NULL DEFAULT now() +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (airport_sk) +``` + +### 3.3. dds.dim_airplanes +```sql +airplane_sk INTEGER NOT NULL +airplane_bk TEXT NOT NULL -- airplane_code +model TEXT NOT NULL +range_km INTEGER +speed_kmh INTEGER +total_seats INTEGER -- COUNT(*) из ods.seats +created_at TIMESTAMP NOT NULL DEFAULT now() +updated_at TIMESTAMP NOT NULL DEFAULT now() +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (airplane_sk) +``` + +### 3.4. dds.dim_tariffs +```sql +tariff_sk INTEGER NOT NULL +fare_conditions TEXT NOT NULL -- business key = fare_conditions +created_at TIMESTAMP NOT NULL DEFAULT now() +updated_at TIMESTAMP NOT NULL DEFAULT now() +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (tariff_sk) +``` + +### 3.5. dds.dim_passengers +```sql +passenger_sk INTEGER NOT NULL +passenger_bk TEXT NOT NULL -- passenger_id +passenger_name TEXT NOT NULL +created_at TIMESTAMP NOT NULL DEFAULT now() +updated_at TIMESTAMP NOT NULL DEFAULT now() +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (passenger_sk) +``` + +### 3.6. dds.dim_routes (SCD2) +```sql +route_sk INTEGER NOT NULL +route_bk TEXT NOT NULL -- route_no (бизнес-ключ) +departure_airport TEXT NOT NULL +arrival_airport TEXT NOT NULL +airplane_code TEXT NOT NULL +days_of_week TEXT +departure_time TIME +duration INTERVAL +hashdiff TEXT NOT NULL -- md5 хэш атрибутов для детекта изменений +valid_from DATE NOT NULL -- начало действия версии +valid_to DATE -- конец действия (NULL = текущая) +created_at TIMESTAMP NOT NULL DEFAULT now() +updated_at TIMESTAMP NOT NULL DEFAULT now() +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (route_sk) +``` + +Поле `validity` из ODS не переносится как отдельная колонка — DWH сам управляет +версиями через `hashdiff` + `valid_from`/`valid_to` (классический SCD2). +Из ODS берём последнюю версию по `route_no` (ORDER BY validity DESC) как "текущее состояние". + +### 3.7. dds.fact_flight_sales +```sql +-- FK на измерения (суррогатные ключи) +calendar_sk INTEGER +departure_airport_sk INTEGER +arrival_airport_sk INTEGER +airplane_sk INTEGER +tariff_sk INTEGER +passenger_sk INTEGER +route_sk INTEGER + +-- Дегенеративные измерения +book_ref TEXT NOT NULL +ticket_no TEXT NOT NULL +flight_id INTEGER NOT NULL +book_date DATE +seat_no TEXT + +-- Метрики +price NUMERIC(10,2) +is_boarded BOOLEAN NOT NULL + +-- Служебные +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (ticket_no) +``` + +### 3.8. Политика NULL FK в факте + +FK суррогатные ключи разделены на три группы: + +| Группа | FK | NULL допустим? | Причина | +|--------|-----|---------------|---------| +| **Обязательные** | `tariff_sk`, `passenger_sk` | Нет | Данные всегда есть в ODS (segments, tickets). NULL = баг загрузки. | +| **Зависят от маршрута** | `route_sk`, `departure_airport_sk`, `arrival_airport_sk`, `airplane_sk` | Нет (в норме) | Маршрут должен быть в ODS. NULL = аномалия данных, DQ предупреждает. | +| **Зависят от расписания** | `calendar_sk` | Допустим (редко) | `scheduled_departure` может быть NULL в ODS. DQ считает и логирует, но не фейлит. | + +DQ-проверки явно контролируют каждую группу (см. секцию 6). + +--- + +## 4) Нейминг служебных полей (консистентно с naming_conventions.md) + +Источник правил: [`docs/design/naming_conventions.md`](naming_conventions.md). + +В DDS используем: + +- `_load_id TEXT NOT NULL` — идентификатор загрузки (`{{ run_id }}` Airflow); +- `_load_ts TIMESTAMP NOT NULL DEFAULT now()` — время загрузки в DDS; +- `created_at TIMESTAMP NOT NULL DEFAULT now()` — когда строка создана в таблице; +- `updated_at TIMESTAMP NOT NULL DEFAULT now()` — когда строка обновлена; +- `valid_from DATE NOT NULL` — начало действия версии SCD2; +- `valid_to DATE` — конец действия SCD2 (`NULL` = текущая версия); +- `hashdiff TEXT NOT NULL` — md5 хэш атрибутов для детекта изменений SCD2; +- `*_bk TEXT` — бизнес-ключ измерения (суффикс `_bk`); +- `*_sk INTEGER` — суррогатный ключ измерения (суффикс `_sk`). + +### 4.1. Почему `{{ run_id }}` вместо `stg_batch_id` + +DDS читает **текущее состояние ODS** (ODS = SCD1, current state). Привязка к stg_batch_id +не требуется. `_load_id` в DDS = Airflow run_id текущего запуска DDS DAG — для аудита +"когда и каким запуском были загружены данные в DDS". + +--- + +## 5) SQL-паттерны загрузки + +> **Стиль SQL:** CTE (Common Table Expressions) — как в ODS. + +### 5.1. dim_calendar — статическая, INSERT если пуста + +```sql +-- Загрузка DDS dim_calendar: статическое измерение (генерация дат). +-- Заполняем только если таблица пуста (идемпотентно). + +INSERT INTO dds.dim_calendar ( + calendar_sk, date_actual, year_actual, month_actual, + day_actual, day_of_week, day_name, is_weekend +) +SELECT + ROW_NUMBER() OVER (ORDER BY d.date_actual)::INTEGER AS calendar_sk, + d.date_actual, + EXTRACT(YEAR FROM d.date_actual)::INTEGER AS year_actual, + EXTRACT(MONTH FROM d.date_actual)::INTEGER AS month_actual, + EXTRACT(DAY FROM d.date_actual)::INTEGER AS day_actual, + EXTRACT(ISODOW FROM d.date_actual)::INTEGER AS day_of_week, + TO_CHAR(d.date_actual, 'FMDay') AS day_name, + EXTRACT(ISODOW FROM d.date_actual) IN (6, 7) AS is_weekend +FROM ( + SELECT generate_series('2016-01-01'::DATE, '2030-12-31'::DATE, '1 day'::INTERVAL)::DATE + AS date_actual +) AS d +WHERE NOT EXISTS (SELECT 1 FROM dds.dim_calendar LIMIT 1); + +ANALYZE dds.dim_calendar; +``` + +### 5.2. dim_airports, dim_airplanes, dim_tariffs, dim_passengers — SCD1 UPSERT + +> Все SCD1-измерения используют один и тот же паттерн: UPDATE существующих + INSERT новых +> с `MAX(sk) + ROW_NUMBER()` для стабильных суррогатных ключей. + +Паттерн (на примере airports): +```sql +-- Statement 1: UPDATE существующих записей (если атрибуты изменились) +UPDATE dds.dim_airports AS d +SET airport_name = s.airport_name, + city = s.city, + country = s.country, + timezone = s.timezone, + coordinates = s.coordinates, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM ods.airports AS s +WHERE d.airport_bk = s.airport_code + AND (d.airport_name IS DISTINCT FROM s.airport_name + OR d.city IS DISTINCT FROM s.city + OR d.country IS DISTINCT FROM s.country + OR d.timezone IS DISTINCT FROM s.timezone + OR d.coordinates IS DISTINCT FROM s.coordinates); + +-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()) +WITH max_sk AS ( + SELECT COALESCE(MAX(airport_sk), 0) AS v FROM dds.dim_airports +) +INSERT INTO dds.dim_airports ( + airport_sk, airport_bk, airport_name, city, country, + timezone, coordinates, created_at, updated_at, _load_id, _load_ts +) +SELECT + (SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.airport_code)::INTEGER, + s.airport_code, s.airport_name, s.city, s.country, + s.timezone, s.coordinates, + now(), now(), '{{ run_id }}', now() +FROM ods.airports AS s +WHERE NOT EXISTS ( + SELECT 1 FROM dds.dim_airports d WHERE d.airport_bk = s.airport_code +); + +ANALYZE dds.dim_airports; +``` + +**dim_airplanes** — аналогично, но с LEFT JOIN на `(SELECT airplane_code, COUNT(*) AS total_seats FROM ods.seats GROUP BY 1)` для обогащения `total_seats`. + +**dim_tariffs** — аналогично, но источник: `SELECT DISTINCT fare_conditions FROM ods.segments WHERE fare_conditions IS NOT NULL AND fare_conditions <> ''`. + +**dim_passengers** — аналогично, но с дедупликацией: `ROW_NUMBER() OVER (PARTITION BY passenger_id ORDER BY event_ts DESC NULLS LAST, _load_ts DESC, ticket_no DESC)`, берём `rn = 1`. Третий ключ `ticket_no DESC` — стабильный tie-breaker при одинаковых timestamp. + +### 5.3. dim_routes — SCD2 с hashdiff + +```sql +-- CTE: текущее состояние маршрутов из ODS (последняя версия по validity). +-- В учебных целях используем классический SCD2 с hashdiff для демонстрации +-- паттерна. Хотя у routes в источнике есть поле validity, мы не опираемся +-- на него для версионирования — DWH сам детектит изменения атрибутов через хэш. + +-- Statement 1: Закрыть устаревшие версии (valid_to = текущая дата) +WITH src 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, '') || '|' || + 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 +) +UPDATE dds.dim_routes AS d +SET valid_to = CURRENT_DATE, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM 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: Закрыть "исчезнувшие" маршруты +-- (есть в текущем срезе DDS, но отсутствуют в текущем состоянии ODS). +WITH src AS ( + SELECT + route_no, + ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn + FROM ods.routes +) +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 src AS s + WHERE s.rn = 1 + AND s.route_no = d.route_bk + ); + +-- Statement 2: Вставить новые версии (для изменённых и совсем новых route_no) +WITH src 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, '') || '|' || + 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 +), +max_sk AS ( + 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, + 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, + s.days_of_week, + s.departure_time, + s.duration, + s.hashdiff, + -- valid_from: для совсем новых route_no — sentinel '1900-01-01' + -- (чтобы point-in-time lookup покрыл все исторические рейсы); + -- для обновлённых (уже были в DDS, но hashdiff изменился) — CURRENT_DATE. + CASE + WHEN EXISTS ( + SELECT 1 FROM dds.dim_routes d2 WHERE d2.route_bk = s.route_no + ) THEN CURRENT_DATE + ELSE '1900-01-01'::DATE + END AS valid_from, + NULL, -- valid_to = NULL (текущая версия) + now(), now(), '{{ run_id }}', now() +FROM src AS s +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 FROM dds.dim_routes d + WHERE d.route_bk = s.route_no + AND d.valid_to IS NULL + AND d.hashdiff = s.hashdiff + ); + +ANALYZE dds.dim_routes; +``` + +Примечание: `valid_from`/`valid_to` имеют дневную гранулярность (`DATE`). +Если маршрут меняется несколько раз в один день, допускается закрытая версия с +`valid_from = valid_to` (нулевой интервал), чтобы не терять факт изменения. + +### 5.4. fact_flight_sales — инкрементальный UPSERT + +```sql +-- Statement 1: UPDATE существующих строк факта. +-- ВАЖНО: обновляем ТОЛЬКО мутабельные поля (is_boarded, seat_no, price). +-- Dimension SK (route_sk, airport_sk, airplane_sk и т.д.) НЕ перезаписываем — +-- они зафиксированы на момент INSERT и отражают историческое состояние. +UPDATE dds.fact_flight_sales AS f +SET seat_no = bp.seat_no, + price = seg.segment_amount, + is_boarded = (bp.ticket_no IS NOT NULL), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM ods.segments AS seg +LEFT JOIN ods.boarding_passes AS bp + ON bp.ticket_no = seg.ticket_no AND bp.flight_id = seg.flight_id +WHERE f.ticket_no = seg.ticket_no + AND f.flight_id = seg.flight_id + AND (f.is_boarded IS DISTINCT FROM (bp.ticket_no IS NOT NULL) + OR f.price IS DISTINCT FROM seg.segment_amount + OR f.seat_no IS DISTINCT FROM bp.seat_no); + +-- Statement 2: INSERT новых строк факта. +-- Dimension SK фиксируются на момент вставки (point-in-time для SCD2 routes). +WITH fact_src AS ( + SELECT + seg.ticket_no, + seg.flight_id, + cal.calendar_sk, + dep.airport_sk AS departure_airport_sk, + arr.airport_sk AS arrival_airport_sk, + ap.airplane_sk, + tar.tariff_sk, + pax.passenger_sk, + rte.route_sk, + tkt.book_ref, + bkg.book_date::DATE AS book_date, + bp.seat_no, + seg.segment_amount AS price, + (bp.ticket_no IS NOT NULL) AS is_boarded + FROM ods.segments AS seg + JOIN ods.tickets AS tkt ON tkt.ticket_no = seg.ticket_no + JOIN ods.bookings AS bkg ON bkg.book_ref = tkt.book_ref + JOIN ods.flights AS flt ON flt.flight_id = seg.flight_id + -- SCD2 point-in-time: версия маршрута, актуальная на дату вылета + 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_calendar AS cal ON cal.date_actual = flt.scheduled_departure::DATE + LEFT JOIN dds.dim_airports AS dep ON dep.airport_bk = rte.departure_airport + LEFT JOIN dds.dim_airports AS arr ON arr.airport_bk = rte.arrival_airport + LEFT JOIN dds.dim_airplanes AS ap ON ap.airplane_bk = rte.airplane_code + LEFT JOIN dds.dim_tariffs AS tar ON tar.fare_conditions = seg.fare_conditions + LEFT JOIN dds.dim_passengers AS pax ON pax.passenger_bk = tkt.passenger_id + LEFT JOIN ods.boarding_passes AS bp + ON bp.ticket_no = seg.ticket_no AND bp.flight_id = seg.flight_id +) +INSERT INTO dds.fact_flight_sales ( + calendar_sk, departure_airport_sk, arrival_airport_sk, airplane_sk, + tariff_sk, passenger_sk, route_sk, + book_ref, ticket_no, flight_id, book_date, seat_no, + price, is_boarded, _load_id, _load_ts +) +SELECT + s.calendar_sk, s.departure_airport_sk, s.arrival_airport_sk, s.airplane_sk, + s.tariff_sk, s.passenger_sk, s.route_sk, + s.book_ref, s.ticket_no, s.flight_id, s.book_date, s.seat_no, + s.price, s.is_boarded, + '{{ run_id }}', now() +FROM fact_src AS s +WHERE NOT EXISTS ( + SELECT 1 FROM dds.fact_flight_sales f + WHERE f.ticket_no = s.ticket_no AND f.flight_id = s.flight_id +); + +ANALYZE dds.fact_flight_sales; +``` + +### 5.5. Модель историчности факта + +Dimension SK фиксируются **при INSERT** и не перезаписываются: +- `route_sk` — версия маршрута на дату `scheduled_departure` (point-in-time SCD2 lookup); +- `departure_airport_sk`, `arrival_airport_sk`, `airplane_sk` — из той же версии маршрута; +- `calendar_sk`, `tariff_sk`, `passenger_sk` — из текущих SCD1-измерений на момент INSERT. + +UPDATE факта обновляет только **мутабельные поля**: `is_boarded`, `seat_no`, `price` +(появился посадочный, изменилась цена). Это гарантирует, что аналитика по историческим +периодам использует правильные версии измерений. + +### 5.6. Политика backfill/reprocess + +- **Повторный запуск** с теми же данными ODS — безопасен (идемпотентно). +- **Повторный запуск после изменения маршрутов в ODS**: dim_routes создаст новую SCD2-версию; + уже вставленные строки факта сохранят старый `route_sk` (историчность). + Новые строки факта получат актуальный `route_sk` через point-in-time lookup. +- **Полная пересборка факта**: если нужна — `TRUNCATE dds.fact_flight_sales` и повторный + запуск DAG. Все SK будут пересчитаны через point-in-time lookup. + +### 5.7. Идемпотентность паттернов + +- **dim_calendar**: `WHERE NOT EXISTS` — повторный запуск не создаёт дублей. +- **SCD1 измерения**: `UPDATE + INSERT WHERE NOT EXISTS` — натурально идемпотентно (как в ODS). +- **SCD2 dim_routes**: `UPDATE changed` + `UPDATE missing` + `INSERT WHERE NOT EXISTS (bk + valid_to IS NULL + hashdiff =)` — повторный запуск с теми же данными ODS не создаёт дублей и не закрывает версии повторно. +- **fact_flight_sales**: `UPDATE + INSERT WHERE NOT EXISTS` — идемпотентно по зерну. + +--- + +## 6) DQ-проверки + +Каждый DQ-скрипт: PL/pgSQL `DO $$` блок, `RAISE EXCEPTION` при нарушении (как в ODS). + +### 6.1. Обязательные проверки по типам + +**Все измерения (кроме calendar):** +1. Таблица не пуста +2. Нет дублей по `_sk` +3. Нет дублей по `_bk` (для SCD1; для SCD2 — нет дублей по `_bk` WHERE `valid_to IS NULL`) +4. Покрытие ODS: все ключи из ODS присутствуют в DDS +5. Обязательные поля не NULL/пустые + +**dim_calendar:** +1. Не менее 1000 строк +2. Нет дублей по `calendar_sk` и `date_actual` +3. Обязательные поля не NULL +4. Покрывает диапазон дат из `ods.flights.scheduled_departure` (для NOT NULL) + +**dim_routes (SCD2 специфика):** +1. Не более одной текущей версии на `route_bk` (`WHERE valid_to IS NULL` — уникальность) +2. `hashdiff` не NULL/пустой +3. `valid_from` не NULL +4. Корректность интервалов: `valid_from <= valid_to` для всех закрытых версий (DATE-гранулярность) +5. Нет перекрытий версий: для одного `route_bk` интервалы `[valid_from, valid_to)` не пересекаются +6. Покрытие: все `route_no` из ODS имеют хотя бы одну версию в DDS +7. Текущий срез DDS консистентен с ODS: `route_bk` с `valid_to IS NULL` есть в `ods.routes` + +**fact_flight_sales:** +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% строк +6. **Calendar**: `calendar_sk IS NULL` — допустимо если `scheduled_departure IS NULL` в ODS; считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк +7. Обязательные поля: `book_ref`, `ticket_no`, `flight_id`, `is_boarded` не NULL + +### 6.2. Пример DQ для dim_routes (SCD2) + +```sql +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; + + -- SCD2: корректность интервалов (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; + + -- SCD2: нет перекрытий интервалов для одного route_bk + SELECT COUNT(*) INTO v_overlap_count + FROM ( + SELECT 1 + FROM dds.dim_routes d1 + JOIN dds.dim_routes 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 overlaps; + IF v_overlap_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_routes найдены перекрытия SCD2-интервалов: %', + v_overlap_count; + END IF; + + -- SCD2: не более одной текущей версии на 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 имеют хотя бы одну версию) + 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 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; + + -- SCD2: 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 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 $$; +``` + +### 6.3. Пример DQ для fact_flight_sales + +```sql +DO $$ +DECLARE + v_row_count BIGINT; + v_ods_count BIGINT; + v_dup_count BIGINT; + v_null_passenger BIGINT; + v_null_tariff BIGINT; + v_null_route_related BIGINT; + v_null_calendar BIGINT; + v_null_required BIGINT; +BEGIN + -- Таблица не пуста + SELECT COUNT(*) INTO v_row_count FROM dds.fact_flight_sales; + IF v_row_count = 0 THEN + RAISE EXCEPTION 'DQ FAILED: dds.fact_flight_sales пуста.'; + END IF; + + -- Покрытие: количество строк = ods.segments + SELECT COUNT(*) INTO v_ods_count FROM ods.segments; + IF v_row_count <> v_ods_count THEN + RAISE EXCEPTION + 'DQ FAILED: dds.fact_flight_sales (%) <> ods.segments (%). Потеряны строки.', + v_row_count, v_ods_count; + END IF; + + -- Нет дублей по зерну + SELECT COUNT(*) INTO v_dup_count + FROM ( + SELECT ticket_no, flight_id + FROM dds.fact_flight_sales + GROUP BY ticket_no, flight_id + HAVING COUNT(*) > 1 + ) AS d; + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.fact_flight_sales дубликаты (ticket_no, flight_id): %', + v_dup_count; + END IF; + + -- Ссылочная целостность: passenger_sk + 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: %', v_null_passenger; + END IF; + + -- Ссылочная целостность: tariff_sk + SELECT COUNT(*) INTO v_null_tariff + FROM dds.fact_flight_sales WHERE tariff_sk IS NULL; + IF v_null_tariff <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales строки без tariff_sk: %', v_null_tariff; + END IF; + + -- FK маршрута: route-related группа (допустимо при аномалиях, фейлим если > 1%) + SELECT COUNT(*) INTO v_null_route_related + 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; + IF v_null_route_related > 0 THEN + IF v_null_route_related * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales слишком много строк с NULL в route-related FK: % (>1%%)', + v_null_route_related; + ELSE + RAISE NOTICE + 'DQ WARNING: в fact_flight_sales строк с NULL в route-related FK: % (<=1%%, допустимо)', + v_null_route_related; + END IF; + END IF; + + -- Calendar: calendar_sk (допустимо если scheduled_departure IS NULL) + SELECT COUNT(*) INTO v_null_calendar + FROM dds.fact_flight_sales WHERE calendar_sk IS NULL; + IF v_null_calendar > 0 THEN + IF v_null_calendar * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales слишком много строк без calendar_sk: % (>1%%)', + v_null_calendar; + ELSE + RAISE NOTICE + 'DQ WARNING: в fact_flight_sales строк без calendar_sk: % (<=1%%, допустимо)', + v_null_calendar; + END IF; + END IF; + + -- Обязательные поля + SELECT COUNT(*) INTO v_null_required + FROM dds.fact_flight_sales + WHERE book_ref IS NULL OR book_ref = '' + OR ticket_no IS NULL OR ticket_no = '' + OR flight_id IS NULL + OR is_boarded IS NULL + OR _load_id IS NULL OR _load_id = '' + OR _load_ts IS NULL; + IF v_null_required <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales NULL обязательные поля: %', v_null_required; + END IF; + + RAISE NOTICE 'DQ PASSED: dds.fact_flight_sales ок, строк=%', v_row_count; +END $$; +``` + +--- + +## 7) Структура файлов + +```text +sql/dds/ (21 SQL-файл) +├── dim_calendar_ddl.sql +├── dim_calendar_load.sql +├── dim_calendar_dq.sql +├── dim_airports_ddl.sql +├── dim_airports_load.sql +├── dim_airports_dq.sql +├── dim_airplanes_ddl.sql +├── dim_airplanes_load.sql +├── dim_airplanes_dq.sql +├── dim_tariffs_ddl.sql +├── dim_tariffs_load.sql +├── dim_tariffs_dq.sql +├── dim_passengers_ddl.sql +├── dim_passengers_load.sql +├── dim_passengers_dq.sql +├── dim_routes_ddl.sql +├── dim_routes_load.sql +├── dim_routes_dq.sql +├── fact_flight_sales_ddl.sql +├── fact_flight_sales_load.sql +└── fact_flight_sales_dq.sql + +airflow/dags/ (2 новых DAG) +├── bookings_dds_ddl.py +└── bookings_to_gp_dds.py + +sql/ddl_gp.sql (+ \i dds/*_ddl.sql в конец) +tests/test_dags_smoke.py (+ 2 smoke-теста) +docs/bookings_to_gp_dds.md (документация для студентов) +docs/design/bookings_dds_design.md (этот план) +docs/design/db_schema.md (обновить: добавить dim_routes, статус DDS) +``` + +--- + +## 8) DAG `bookings_dds_ddl` + +По аналогии с `bookings_ods_ddl.py` (`airflow/dags/bookings_ods_ddl.py`). + +**Ключевые параметры:** +- `dag_id = "bookings_dds_ddl"` +- `schedule = None` +- `template_searchpath = "/sql"` +- `tags = ["demo", "greenplum", "ddl", "bookings", "dds"]` +- `description = "Учебный DDL DAG: создаёт/обновляет dds.* для bookings"` + +**Задачи (линейная цепочка из 7 задач):** +1. `apply_dds_dim_calendar_ddl` — `dds/dim_calendar_ddl.sql` +2. `apply_dds_dim_airports_ddl` — `dds/dim_airports_ddl.sql` +3. `apply_dds_dim_airplanes_ddl` — `dds/dim_airplanes_ddl.sql` +4. `apply_dds_dim_tariffs_ddl` — `dds/dim_tariffs_ddl.sql` +5. `apply_dds_dim_passengers_ddl` — `dds/dim_passengers_ddl.sql` +6. `apply_dds_dim_routes_ddl` — `dds/dim_routes_ddl.sql` +7. `apply_dds_fact_flight_sales_ddl` — `dds/fact_flight_sales_ddl.sql` + +--- + +## 9) DAG `bookings_to_gp_dds`: граф зависимостей + +По аналогии с `bookings_to_gp_ods.py` (`airflow/dags/bookings_to_gp_ods.py`). + +**Ключевые параметры:** +- `dag_id = "bookings_to_gp_dds"` +- `schedule = None`, `max_active_runs = 1` +- `_load_id = {{ run_id }}` (не нужен `resolve_stg_batch_id`) + +### 9.1. Граф + +```text +load_dds_dim_calendar -> dq_dds_dim_calendar + | + v (после calendar — параллельно 5 измерений) +load_dds_dim_airports -> dq_dds_dim_airports +load_dds_dim_airplanes -> dq_dds_dim_airplanes +load_dds_dim_tariffs -> dq_dds_dim_tariffs +load_dds_dim_passengers -> dq_dds_dim_passengers +load_dds_dim_routes -> dq_dds_dim_routes + | + v (факт после ВСЕХ 6 измерений) +load_dds_fact_flight_sales -> dq_dds_fact_flight_sales -> finish_dds_summary +``` + +Задач: 7 load + 7 dq + 1 finish = **15 задач**. + +### 9.2. Зависимости (Python) + +```python +load_dds_dim_calendar >> dq_dds_dim_calendar + +# 5 измерений параллельно после calendar +dq_dds_dim_calendar >> [ + load_dds_dim_airports, load_dds_dim_airplanes, + load_dds_dim_tariffs, load_dds_dim_passengers, + load_dds_dim_routes +] + +load_dds_dim_airports >> dq_dds_dim_airports +load_dds_dim_airplanes >> dq_dds_dim_airplanes +load_dds_dim_tariffs >> dq_dds_dim_tariffs +load_dds_dim_passengers >> dq_dds_dim_passengers +load_dds_dim_routes >> dq_dds_dim_routes + +# Факт после всех измерений +[dq_dds_dim_airports, dq_dds_dim_airplanes, + dq_dds_dim_tariffs, dq_dds_dim_passengers, + dq_dds_dim_routes] >> load_dds_fact_flight_sales + +load_dds_fact_flight_sales >> dq_dds_fact_flight_sales >> finish_dds_summary +``` + +### 9.3. Почему calendar первая + +Факт ссылается на `calendar_sk`. Calendar — статическая таблица, заполняется один раз. +Но если DDS запускается впервые, calendar должна быть заполнена до загрузки факта. +Остальные 5 измерений не зависят друг от друга в DDS (FK-зависимости уже проверены в ODS). + +--- + +## 10) Smoke-тесты + +Добавить в `tests/test_dags_smoke.py` два теста: + +### test_bookings_dds_ddl_dag_structure +- 7 задач: `apply_dds_dim_{calendar,airports,airplanes,tariffs,passengers,routes}_ddl`, `apply_dds_fact_flight_sales_ddl` +- Линейная цепочка: каждая задача reachable от предыдущей + +### test_bookings_to_gp_dds_dag_structure +- 15 задач (7 load + 7 dq + `finish_dds_summary`) +- `load → dq` для каждого объекта (direct edge) +- `dq_dds_dim_calendar` → все 5 остальных load-измерений +- airports и airplanes не зависят друг от друга (параллельность) +- факт reachable от всех 6 dq измерений (через `[...] >> load_dds_fact`) +- `finish_dds_summary` reachable от `dq_dds_fact_flight_sales` + +--- + +## 11) Порядок реализации + +1. DDL: 7 файлов `sql/dds/*_ddl.sql` (calendar, airports, airplanes, tariffs, passengers, routes, fact) +2. Подключить DDL в `sql/ddl_gp.sql` (добавить `\i dds/*_ddl.sql`) +3. DAG `airflow/dags/bookings_dds_ddl.py` +4. Load SQL: 7 файлов `sql/dds/*_load.sql` +5. DQ SQL: 7 файлов `sql/dds/*_dq.sql` +6. DAG `airflow/dags/bookings_to_gp_dds.py` +7. Smoke-тесты в `tests/test_dags_smoke.py` (+2 теста) +8. Документация `docs/bookings_to_gp_dds.md` +9. Обновить `docs/design/db_schema.md` — отразить `dim_routes` и актуальный статус DDS + +Итого: **21 SQL-файл** + **2 DAG** + **обновления 3 существующих файлов** + **1 новый doc-файл**. + +--- + +## 12) Критические файлы-образцы (patterns to follow) + +| Что реализуем | Образец в репозитории | +|---------------|---------| +| DDS DDL DAG | `airflow/dags/bookings_ods_ddl.py` | +| DDS ETL DAG | `airflow/dags/bookings_to_gp_ods.py` | +| DDL SQL | `sql/ods/airports_ddl.sql` | +| SCD1 UPSERT SQL | `sql/ods/airports_load.sql`, `sql/ods/bookings_load.sql` | +| DQ SQL (PL/pgSQL) | `sql/ods/airports_dq.sql`, `sql/ods/segments_dq.sql` | +| Smoke-тесты | `tests/test_dags_smoke.py` (тесты ODS DAG) | +| Подключение DDL | `sql/ddl_gp.sql` (секция DDS `\i` директивы) | + +--- + +## 13) Критерии готовности (Definition of Done) + +1. Оба новых DAG парсятся и проходят smoke-тесты (`make test`) +2. `make ddl-gp` создаёт STG+ODS+DDS без ошибок +3. DAG `bookings_to_gp_dds` завершается успешно после ODS +4. Все DQ-задачи зелёные +5. В DDS нет дублей по SK; для SCD1 нет дублей по BK, для SCD2 не более одной current-версии BK +6. `fact_flight_sales` содержит столько строк, сколько в `ods.segments` +7. Нейминг консистентен: `_bk`, `_sk`, `valid_from`/`valid_to`, `hashdiff`, `_load_id`, `_load_ts`, `created_at`/`updated_at` +8. `make fmt` / `make lint` проходят +9. `dim_routes` демонстрирует SCD2 с реальными версиями + +--- + +## 14) Как проверять вручную + +```bash +make up +make ddl-gp # создать STG+ODS+DDS-объекты +# Trigger bookings_to_gp_stage (загрузить STG) +# Trigger bookings_to_gp_ods (загрузить ODS) +# Trigger bookings_to_gp_dds (загрузить DDS) +make gp-psql +``` + +Проверочные SQL: + +```sql +-- 1) Количество строк в измерениях и факте +SELECT 'dim_calendar' AS tbl, COUNT(*) FROM dds.dim_calendar +UNION ALL +SELECT 'dim_airports', COUNT(*) FROM dds.dim_airports +UNION ALL +SELECT 'dim_airplanes', COUNT(*) FROM dds.dim_airplanes +UNION ALL +SELECT 'dim_tariffs', COUNT(*) FROM dds.dim_tariffs +UNION ALL +SELECT 'dim_passengers', COUNT(*) FROM dds.dim_passengers +UNION ALL +SELECT 'dim_routes', COUNT(*) FROM dds.dim_routes +UNION ALL +SELECT 'fact_flight_sales', COUNT(*) FROM dds.fact_flight_sales; + +-- 2) Покрытие факта: должно совпадать с ods.segments +SELECT + (SELECT COUNT(*) FROM dds.fact_flight_sales) AS fact_rows, + (SELECT COUNT(*) FROM ods.segments) AS ods_rows; + +-- 3) SCD2 dim_routes: версии маршрутов +SELECT route_bk, COUNT(*) AS versions +FROM dds.dim_routes +GROUP BY route_bk +HAVING COUNT(*) > 1 +ORDER BY versions DESC; + +-- 4) NULL суррогатные ключи в факте (потенциальные аномалии) +SELECT + SUM(CASE WHEN calendar_sk IS NULL THEN 1 ELSE 0 END) AS null_calendar, + SUM(CASE WHEN departure_airport_sk IS NULL THEN 1 ELSE 0 END) AS null_dep_airport, + SUM(CASE WHEN arrival_airport_sk IS NULL THEN 1 ELSE 0 END) AS null_arr_airport, + SUM(CASE WHEN airplane_sk IS NULL THEN 1 ELSE 0 END) AS null_airplane, + SUM(CASE WHEN tariff_sk IS NULL THEN 1 ELSE 0 END) AS null_tariff, + SUM(CASE WHEN passenger_sk IS NULL THEN 1 ELSE 0 END) AS null_passenger, + SUM(CASE WHEN route_sk IS NULL THEN 1 ELSE 0 END) AS null_route +FROM dds.fact_flight_sales; + +-- 5) Пример аналитического запроса: выручка по тарифам +SELECT + t.fare_conditions, + COUNT(*) AS segments, + SUM(f.price) AS total_revenue, + AVG(f.price) AS avg_price +FROM dds.fact_flight_sales AS f +JOIN dds.dim_tariffs AS t ON t.tariff_sk = f.tariff_sk +GROUP BY t.fare_conditions +ORDER BY total_revenue DESC; + +-- 6) Пример запроса с SCD2: маршруты и их версии +SELECT + r.route_bk, + r.departure_airport, + r.arrival_airport, + r.airplane_code, + r.valid_from, + r.valid_to, + COUNT(f.ticket_no) AS fact_rows +FROM dds.dim_routes AS r +LEFT JOIN dds.fact_flight_sales AS f ON f.route_sk = r.route_sk +GROUP BY 1, 2, 3, 4, 5, 6 +ORDER BY r.route_bk, r.valid_from; +``` + +--- + +## 15) Что будет следующим шагом + +- Data Mart (витрина) поверх DDS +- Unknown-member стратегия (`*_sk = 0`) для late-arriving dimensions +- `dim_calendar.is_holiday` (если появится источник) diff --git a/docs/design/bookings_dm_design.md b/docs/design/bookings_dm_design.md new file mode 100644 index 0000000..4a224a3 --- /dev/null +++ b/docs/design/bookings_dm_design.md @@ -0,0 +1,409 @@ +# План: DM-слой (Data Mart) для учебного стенда Bookings + +## Context + +DWH-стенд уже имеет полностью реализованные слои STG (9 таблиц) -> ODS (9 таблиц, SCD1) -> DDS (6 измерений + 1 факт, Star Schema). DM-слой — финальный аналитический слой, который: +- даёт студентам опыт построения витрин поверх Star Schema; +- демонстрирует реалистичные паттерны (UPSERT, full rebuild, AO Column Store); +- служит основой для будущего Superset-дашборда. + +**1 витрина — эталонная** (показывается студенту), **4 остальные — демо/задания**. + +--- + +## 5 витрин DM + +### 1. `dm.sales_report` (ЭТАЛОННАЯ) + +**Бизнес-вопрос**: "Какова выручка, кол-во билетов и boarding rate по направлениям/тарифам за каждый день?" + +**Зерно**: `(flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk)` + +**Поля**: +``` +-- Ключ +flight_date DATE NOT NULL +departure_airport_sk INTEGER NOT NULL +arrival_airport_sk INTEGER NOT NULL +tariff_sk INTEGER NOT NULL +-- Денормализованные атрибуты +departure_city TEXT NOT NULL +departure_airport_bk TEXT NOT NULL +arrival_city TEXT NOT NULL +arrival_airport_bk TEXT NOT NULL +fare_conditions TEXT NOT NULL +day_of_week INTEGER NOT NULL +day_name TEXT NOT NULL +is_weekend BOOLEAN NOT NULL +-- Метрики +tickets_sold INTEGER NOT NULL +passengers_boarded INTEGER NOT NULL +total_revenue NUMERIC(15,2) NOT NULL +avg_price NUMERIC(10,2) NOT NULL +min_price NUMERIC(10,2) +max_price NUMERIC(10,2) +boarding_rate NUMERIC(5,4) NOT NULL -- boarded / sold +-- Служебные +created_at, updated_at, _load_id, _load_ts +``` + +**Источники**: `fact_flight_sales` JOIN `dim_calendar`, `dim_airports` (x2), `dim_tariffs` + +**Загрузка**: Инкрементальный UPSERT по High-Water Mark (`_load_ts`). +*Архитектурный нюанс (HWM-паттерн):* Витрина сравнивает свой `MAX(_load_ts)` с `_load_ts` фактов в DDS и пересчитывает агрегаты только для затронутых дат. Это делает конвейер самовосстанавливающимся: если DAG не запускался несколько дней, при следующем запуске витрина автоматически «догонит» всю накопленную дельту. При этом `_load_id` и `_load_ts` самой витрины фиксируют текущий DM-DAG run_id — для аудита и DQ-проверок. + +**Хранение**: `DISTRIBUTED BY (flight_date)`, heap (нужен UPDATE) + +**Учит**: HWM-инкрементальность через `_load_ts`, TEMP TABLE для однократной агрегации (канон MPP), ограничение радиуса обновления, денормализация измерений, GROUP BY + агрегация, UPSERT по составному ключу, IS DISTINCT FROM + +--- + +### 2. `dm.route_performance` + +**Бизнес-вопрос**: "Какие маршруты самые прибыльные? Каков load factor (заполняемость)?" + +**Зерно**: `(route_bk)` — одна строка на бизнес-ключ маршрута + +**Поля**: +``` +route_bk TEXT NOT NULL -- бизнес-ключ (GROUP BY по нему, чтобы учесть все SCD2-версии) +route_sk INTEGER -- SK текущей версии (для денормализации) +departure_airport_bk, departure_city -- из текущей версии dim_routes +arrival_airport_bk, arrival_city +airplane_bk, airplane_model, total_seats -- из dim_airplanes (через текущую версию маршрута) +-- Метрики +total_flights INTEGER NOT NULL -- COUNT(DISTINCT flight_id) +total_tickets INTEGER NOT NULL +total_boarded INTEGER NOT NULL +total_revenue NUMERIC(15,2) NOT NULL +avg_ticket_price NUMERIC(10,2) +avg_boarding_rate NUMERIC(5,4) NOT NULL +avg_load_factor NUMERIC(5,4) -- AVG(boarded_per_flight / total_seats) +first_flight_date, last_flight_date DATE +-- Служебные +created_at, updated_at, _load_id, _load_ts +``` + +**Источники**: `fact_flight_sales` JOIN `dim_routes` (все версии по route_sk), `dim_airports`, `dim_airplanes`, `dim_calendar` + +**Нюанс SCD2**: Факт содержит `route_sk`, привязанный к конкретной версии. Агрегируем по `route_bk` (через JOIN dim_routes), чтобы собрать метрики **всех** версий. Атрибуты берём из текущей версии (`valid_to IS NULL`). + +**Загрузка**: full rebuild (TRUNCATE + INSERT) — таблица маленькая (~1000 строк) + +**Хранение**: `DISTRIBUTED BY (route_bk)`, **AO Column** (нет UPDATE, чисто аналитические чтения — демонстрация отличия от heap) + +**Учит**: TRUNCATE + INSERT как альтернатива UPSERT, AO Column Store, load factor, агрегация по SCD2 через route_bk, подзапрос для двухуровневой агрегации + +--- + +### 3. `dm.passenger_loyalty` + +**Бизнес-вопрос**: "Кто наши частые пассажиры, сколько тратят, каков их любимый тариф?" + +**Зерно**: `(passenger_sk)` + +**Поля**: +``` +passenger_sk INTEGER NOT NULL +passenger_bk TEXT NOT NULL +passenger_name TEXT NOT NULL +-- Метрики +total_bookings INTEGER NOT NULL -- COUNT(DISTINCT book_ref) +total_flights INTEGER NOT NULL -- COUNT(*) +total_boarded INTEGER NOT NULL +total_spent NUMERIC(15,2) NOT NULL +avg_ticket_price NUMERIC(10,2) +favorite_tariff TEXT -- самый частый тариф (MODE) +unique_routes INTEGER NOT NULL -- COUNT(DISTINCT route_sk) +first_flight_date, last_flight_date DATE +days_as_customer INTEGER -- last - first +-- Служебные +created_at, updated_at, _load_id, _load_ts +``` + +**Источники**: `fact_flight_sales` JOIN `dim_passengers`, `dim_tariffs`, `dim_calendar` + +**Загрузка**: Инкрементальный UPSERT по HWM (`_load_ts`). Из дельты фактов определяем затронутых `passenger_sk`, пересчитываем агрегаты только для них. (664K пассажиров — full rebuild дорогой.) + +**Хранение**: `DISTRIBUTED BY (passenger_sk)`, heap + +**Учит**: HWM-инкрементальность, TEMP TABLE для однократной агрегации, DISTINCT ON / ROW_NUMBER для "самого частого", COUNT(DISTINCT) по нескольким полям, RFM-подобные метрики, UPSERT на большой таблице + +--- + +### 4. `dm.airport_traffic` + +**Бизнес-вопрос**: "Каков ежедневный пассажиропоток аэропорта? Сколько вылетов vs прилётов?" + +**Зерно**: `(traffic_date, airport_sk)` + +**Поля**: +``` +traffic_date DATE NOT NULL +airport_sk INTEGER NOT NULL +airport_bk TEXT NOT NULL +airport_name TEXT NOT NULL +city TEXT NOT NULL +-- Метрики вылета +departures_flights INTEGER NOT NULL DEFAULT 0 +departures_passengers INTEGER NOT NULL DEFAULT 0 +departures_revenue NUMERIC(15,2) NOT NULL DEFAULT 0 +-- Метрики прилёта +arrivals_flights INTEGER NOT NULL DEFAULT 0 +arrivals_passengers INTEGER NOT NULL DEFAULT 0 +arrivals_revenue NUMERIC(15,2) NOT NULL DEFAULT 0 +-- Итого +total_passengers INTEGER NOT NULL -- departures + arrivals +-- Служебные +created_at, updated_at, _load_id, _load_ts +``` + +**Источники**: `fact_flight_sales` JOIN `dim_calendar`, `dim_airports` (dual-role: departure + arrival через UNION ALL в CTE) + +**Ключевой паттерн**: UNION ALL для "разворота" двух ролей аэропорта: +```sql +WITH traffic AS ( + SELECT cal.date_actual, f.departure_airport_sk AS airport_sk, + 'departure' AS direction, ... + FROM fact_flight_sales f JOIN dim_calendar cal ... + UNION ALL + SELECT cal.date_actual, f.arrival_airport_sk AS airport_sk, + 'arrival' AS direction, ... + FROM fact_flight_sales f JOIN dim_calendar cal ... +) +SELECT airport_sk, date_actual, + SUM(CASE WHEN direction='departure' THEN flights END) AS departures_flights, ... +FROM traffic GROUP BY ... +``` + +**Загрузка**: Инкрементальный UPSERT по HWM (`_load_ts`). Из дельты фактов определяем затронутые `(traffic_date, airport_sk)`, пересчитываем агрегаты только для них. + +**Хранение**: `DISTRIBUTED BY (airport_sk)`, heap + +**Учит**: HWM-инкрементальность, TEMP TABLE для однократной агрегации, dual-role dimension join (UNION ALL), conditional aggregation (CASE WHEN + SUM), паттерн "unpivot → aggregate" + +--- + +### 5. `dm.monthly_overview` + +**Бизнес-вопрос**: "Каковы помесячные тренды: выручка, пассажиропоток, заполняемость по типам самолётов?" + +**Зерно**: `(year_actual, month_actual, airplane_sk)` + +**Поля**: +``` +year_actual INTEGER NOT NULL +month_actual INTEGER NOT NULL +airplane_sk INTEGER NOT NULL +airplane_bk TEXT NOT NULL +airplane_model TEXT NOT NULL +total_seats INTEGER +-- Метрики +total_flights INTEGER NOT NULL -- COUNT(DISTINCT flight_id) +total_tickets INTEGER NOT NULL +total_boarded INTEGER NOT NULL +total_revenue NUMERIC(15,2) NOT NULL +avg_ticket_price NUMERIC(10,2) +avg_load_factor NUMERIC(5,4) -- AVG(boarded_per_flight / total_seats) +unique_routes INTEGER NOT NULL +unique_passengers INTEGER NOT NULL +-- Служебные +created_at, updated_at, _load_id, _load_ts +``` + +**Источники**: `fact_flight_sales` JOIN `dim_calendar`, `dim_airplanes` + +**Загрузка**: Инкрементальный UPSERT по HWM (`_load_ts`). Из дельты фактов определяем затронутые `(year_actual, month_actual, airplane_sk)`, пересчитываем агрегаты только для них. + +**Хранение**: `DISTRIBUTED BY (year_actual)`, heap + +**Учит**: HWM-инкрементальность, TEMP TABLE для однократной агрегации, двухуровневая агрегация (сначала по рейсу для load factor, потом по месяцу), NULLIF для деления, COUNT(DISTINCT) на нескольких полях, executive-дашборд + +--- + +## DAG-структура + +### DAG `bookings_dm_ddl` (DDL) + +Линейная цепочка из 5 задач (по аналогии с `bookings_dds_ddl.py`): +``` +apply_dm_sales_report_ddl >> apply_dm_route_performance_ddl +>> apply_dm_passenger_loyalty_ddl >> apply_dm_airport_traffic_ddl +>> apply_dm_monthly_overview_ddl +``` + +### DAG `bookings_to_gp_dm` (ETL + DQ) + +Все 5 витрин **параллельно** (читают из DDS, не зависят друг от друга): +``` + load_dm_sales_report → dq_dm_sales_report ─┐ + load_dm_route_performance → dq_dm_route_performance ─┤ +start_dm ──>> 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 ─┘ +``` + +Задач: 1 (start) + 5 (load) + 5 (dq) + 1 (finish) = **12 задач**. + +--- + +## Файлы для создания/изменения + +### Новые файлы (17 шт.) + +**SQL** (`sql/dm/` — 15 файлов): +1. `sql/dm/sales_report_ddl.sql` +2. `sql/dm/sales_report_load.sql` +3. `sql/dm/sales_report_dq.sql` +4. `sql/dm/route_performance_ddl.sql` +5. `sql/dm/route_performance_load.sql` +6. `sql/dm/route_performance_dq.sql` +7. `sql/dm/passenger_loyalty_ddl.sql` +8. `sql/dm/passenger_loyalty_load.sql` +9. `sql/dm/passenger_loyalty_dq.sql` +10. `sql/dm/airport_traffic_ddl.sql` +11. `sql/dm/airport_traffic_load.sql` +12. `sql/dm/airport_traffic_dq.sql` +13. `sql/dm/monthly_overview_ddl.sql` +14. `sql/dm/monthly_overview_load.sql` +15. `sql/dm/monthly_overview_dq.sql` + +**DAG** (`airflow/dags/` — 2 файла): +16. `airflow/dags/bookings_dm_ddl.py` +17. `airflow/dags/bookings_to_gp_dm.py` + +### Изменяемые файлы (3 шт.) + +18. `sql/ddl_gp.sql` — добавить `\i dm/*_ddl.sql` в конец +19. `tests/test_dags_smoke.py` — 2 новых теста (DDL DAG + ETL DAG) +20. `docs/design/db_schema.md` — добавить DM-слой в описание/Mermaid + +### Документация (2 шт.) + +21. `docs/design/bookings_dm_design.md` — полный дизайн-документ DM-слоя (этот файл) +22. `docs/bookings_to_gp_dm.md` — инструкция для студентов (аналог `bookings_to_gp_dds.md`) + +--- + +## Порядок реализации + +### Этап 1: Инфраструктура + эталонная витрина `dm.sales_report` +- Дизайн-документ `docs/design/bookings_dm_design.md` +- DDL + load + DQ для sales_report +- Оба DAG (изначально с 1 витриной) +- Обновить `ddl_gp.sql` +- Smoke-тесты +- Документация для студентов + +### Этап 2: `dm.route_performance` (full rebuild + AO Column) +- DDL + load + DQ +- Расширить оба DAG и smoke-тесты + +### Этап 3: `dm.passenger_loyalty` +- DDL + load + DQ +- Расширить DAG и тесты + +### Этап 4: `dm.airport_traffic` (dual-role dimension) +- DDL + load + DQ +- Расширить DAG и тесты + +### Этап 5: `dm.monthly_overview` + финализация +- DDL + load + DQ +- Финализировать DAG и тесты +- Обновить `db_schema.md` (Mermaid lineage, статус) + +--- + +## Общий паттерн загрузки UPSERT-витрин (TEMP TABLE + HWM) + +Все инкрементальные витрины (кроме `route_performance` — full rebuild) строятся по единому скелету: + +```sql +-- Шаг 1: Агрегация дельты во временную таблицу. +-- Тяжёлый SELECT с JOIN-ами выполняется ОДИН раз — канон для MPP (Greenplum). +-- Без TEMP TABLE пришлось бы дублировать тот же SELECT в UPDATE и INSERT, +-- что означает двойной скан таблицы фактов. +CREATE TEMP TABLE tmp__delta ON COMMIT DROP AS +SELECT + , + , + +FROM dds.fact_flight_sales AS f +JOIN ... +WHERE IN ( + -- HWM-фильтр: берём только зёрна, затронутые новыми фактами + SELECT DISTINCT + FROM dds.fact_flight_sales AS f_sq + JOIN ... + WHERE f_sq._load_ts > ( + SELECT COALESCE(MAX(_load_ts), '1900-01-01'::TIMESTAMP) + FROM dm. + ) +) +GROUP BY , ; + +-- Шаг 2: UPDATE существующих строк (только если что-то изменилось). +UPDATE dm. AS tgt +SET = src., + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM tmp__delta AS src +WHERE tgt. = src. + AND ( IS DISTINCT FROM ...); + +-- Шаг 3: INSERT новых строк. +INSERT INTO dm. (...) +SELECT ... FROM tmp__delta AS src +WHERE NOT EXISTS ( + SELECT 1 FROM dm. AS tgt + WHERE tgt. = src. +); +``` + +**Применимость по витринам:** +- `sales_report` — TEMP TABLE + HWM UPSERT (эталон, уже реализован) +- `passenger_loyalty` — TEMP TABLE + HWM UPSERT (затронутые `passenger_sk`) +- `airport_traffic` — TEMP TABLE + HWM UPSERT (затронутые `(traffic_date, airport_sk)`) +- `monthly_overview` — TEMP TABLE + HWM UPSERT (затронутые `(year_actual, month_actual, airplane_sk)`) +- `route_performance` — **не использует** (full rebuild: TRUNCATE + INSERT, один проход) + +--- + +## DQ-проверки (общий паттерн для всех витрин) + +PL/pgSQL `DO $$` блоки (как в DDS): +1. Таблица не пуста +2. Нет дублей по составному ключу +3. Бизнес-инварианты (`tickets_sold >= passengers_boarded`, `boarding_rate BETWEEN 0 AND 1`, `total_revenue >= 0`) +4. Обязательные поля не NULL/пустые +5. Для route_performance: `avg_load_factor IS NULL OR avg_load_factor BETWEEN 0 AND 2` + +--- + +## Ключевые образцы для переиспользования + +| Что | Файл-образец | +|-----|--------------| +| ETL DAG (PostgresOperator, зависимости) | `airflow/dags/bookings_to_gp_dds.py` | +| DDL DAG (линейная цепочка) | `airflow/dags/bookings_dds_ddl.py` | +| UPSERT SQL (UPDATE + INSERT + CTE) | `sql/dds/fact_flight_sales_load.sql` | +| HWM-инкремент (UPSERT по _load_ts) | `sql/dm/sales_report_load.sql` | +| DQ PL/pgSQL (RAISE EXCEPTION/NOTICE) | `sql/dds/fact_flight_sales_dq.sql` | +| DDL (CREATE TABLE IF NOT EXISTS) | `sql/dds/dim_airports_ddl.sql` | +| Smoke-тесты DAG | `tests/test_dags_smoke.py` | +| Naming conventions | `docs/design/naming_conventions.md` | + +--- + +## Верификация (end-to-end) + +1. `make fmt && make lint` — код проходит проверки +2. `make test` — smoke-тесты DAG зелёные (включая 2 новых) +3. `make ddl-gp` — DDL всех слоёв (STG + ODS + DDS + DM) применяется без ошибок +4. Запустить `bookings_to_gp_dm` в Airflow → все 12 задач зелёные +5. SQL-проверки в Greenplum: + - `SELECT COUNT(*) FROM dm.sales_report;` — не пусто + - `SELECT COUNT(*) FROM dm.route_performance;` — ~число текущих маршрутов + - Нет дублей по составным ключам + - `boarding_rate BETWEEN 0 AND 1` для всех строк diff --git a/docs/design/bookings_ods_design.md b/docs/design/bookings_ods_design.md new file mode 100644 index 0000000..139382c --- /dev/null +++ b/docs/design/bookings_ods_design.md @@ -0,0 +1,723 @@ +# ODS Layer: эталонный учебный план реализации (v3) + +## Контекст + +STG-слой уже реализован как учебный эталон: +- данные из `bookings-db` читаются через PXF; +- в STG бизнес-колонки хранятся как `TEXT`; +- загрузка и DQ работают батчами (`_load_id = {{ run_id }}`). + +Этот документ фиксирует **простую и каноничную** реализацию ODS для менти. + +--- + +## 1) Что считаем эталоном для ODS + +### 1.1. Роль ODS в этом стенде + +ODS в учебном проекте — это: +- типизированные и очищенные данные; +- одна актуальная запись на бизнес-ключ; +- удобный слой для последующей сборки DDS/DM. + +### 1.2. Что делаем, что не делаем + +Делаем в ODS: +- приведение типов (`TEXT -> TIMESTAMPTZ/NUMERIC/INT/BOOLEAN/...`); +- дедупликацию внутри батча; +- `UPSERT` (SCD Type 1): обновляем текущую запись при изменении, вставляем новые. +- для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) синхронизацию ключей: + удаляем из ODS записи, которых нет в выбранном `stg_batch_id`. + +Не делаем в ODS (в базовом эталоне): +- SCD Type 2 с периодами действия; +- сложную обработку late-arriving/backdated событий; +- отдельный DQ-слой с хранением результатов. + +### 1.3. Где хранится история изменений + +- История «как приходили данные» уже сохраняется в STG (append + `_load_id`). +- Историзацию измерений (SCD2) показываем позже в DDS (как в учебной статье `dwh-modeling`). + +Итог: **ODS = текущий слой (current state), простой и понятный**. + +--- + +## 2) Нейминг служебных полей (консистентно с de-roadmap) + +Источник правил: [`docs/design/naming_conventions.md`](naming_conventions.md). + +В ODS используем такие техполя: + +- `_load_id TEXT NOT NULL` — идентификатор загрузки (берём `stg_batch_id`); +- `_load_ts TIMESTAMP NOT NULL DEFAULT now()` — время загрузки в ODS; +- `event_ts TIMESTAMP` — время события из источника (если у сущности оно есть). + +### 2.1. Маппинг из текущего STG + +- `stg._load_id` -> `ods._load_id` +- `stg._load_ts` не переносим 1:1; в ODS пишем собственный `ods._load_ts = now()` +- `stg.event_ts` -> `ods.event_ts` (для транзакционных таблиц) + +### 2.2. Почему так + +- нейминг совпадает с учебной статьёй (`_load_id`, `_load_ts`); +- студентам проще переносить паттерн между проектами; +- разделяем «когда событие произошло» (`event_ts`) и «когда загрузили в слой» (`_load_ts`). + +--- + +## 3) Гранулярность и бизнес-ключи ODS + +| Таблица | Зерно | Бизнес-ключ | +|---|---|---| +| `ods.airports` | 1 строка = аэропорт | `airport_code` | +| `ods.airplanes` | 1 строка = самолёт | `airplane_code` | +| `ods.routes` | 1 строка = версия маршрута | `(route_no, validity)` | +| `ods.seats` | 1 строка = место в самолёте | `(airplane_code, seat_no)` | +| `ods.bookings` | 1 строка = бронирование | `book_ref` | +| `ods.tickets` | 1 строка = билет | `ticket_no` | +| `ods.flights` | 1 строка = рейс | `flight_id` | +| `ods.segments` | 1 строка = сегмент билета | `(ticket_no, flight_id)` | +| `ods.boarding_passes` | 1 строка = посадочный на сегмент | `(ticket_no, flight_id)` | + +Критично для эталона: +- `routes` — **составной** ключ `(route_no, validity)`: один `route_no` может иметь несколько версий с разными периодами действия. `flights` ссылается только на `route_no` (без `validity`), поэтому при join в DDS нужно будет выбирать подходящую версию маршрута; +- `boarding_passes` — **составной** ключ `(ticket_no, flight_id)`. + +--- + +## 4) Схема ODS-таблиц (v1, без SCD2) + +Ниже — учебный минимум колонок. При необходимости можно добавлять бизнес-атрибуты без изменения паттерна загрузки. + +### 4.1. Справочники + +#### `ods.airports` +```sql +airport_code TEXT NOT NULL +airport_name TEXT NOT NULL -- русское название, извлечено из JSON (поле 'ru') +city TEXT NOT NULL -- русское название, извлечено из JSON (поле 'ru') +country TEXT NOT NULL -- русское название, извлечено из JSON (поле 'ru') +coordinates TEXT +timezone TEXT NOT NULL +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (airport_code) +``` + +> **Примечание о нормализации JSON**: Источник (`bookings.airports_data`) хранит +> мультиязычные поля (`airport_name`, `city`, `country`) как JSON-объекты вида +> `{"en": "...", "ru": "..."}`. На уровне ODS выполняется нормализация: +> извлекается только русский вариант (`->>'ru'`). Это упрощает downstream-логику +> (DDS/DM не нужно знать о внутренней структуре JSON) и соответствует принципу +> "типизированные данные" на уровне ODS. + +#### `ods.airplanes` +```sql +airplane_code TEXT NOT NULL +model TEXT NOT NULL -- русское название модели, извлечено из JSON (поле 'ru') +range_km INTEGER +speed_kmh INTEGER +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (airplane_code) +``` + +> **Примечание о нормализации JSON**: Источник (`bookings.aircrafts_data`) хранит +> поле `model` как JSON-объект `{"en": "...", "ru": "..."}`. На уровне ODS +> извлекается только русский вариант (`->>'ru'`) для консистентности с `airports`. + +#### `ods.routes` +```sql +route_no TEXT NOT NULL +validity TEXT NOT NULL +departure_airport TEXT NOT NULL +arrival_airport TEXT NOT NULL +airplane_code TEXT NOT NULL +days_of_week TEXT +departure_time TIME -- STG: scheduled_time (TEXT → TIME) +duration INTERVAL -- STG: duration (TEXT → INTERVAL) +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (route_no) +``` + +#### `ods.seats` +```sql +airplane_code TEXT NOT NULL +seat_no TEXT NOT NULL +fare_conditions TEXT NOT NULL +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (airplane_code) +``` + +### 4.2. Транзакционные + +#### `ods.bookings` +```sql +book_ref TEXT NOT NULL +book_date TIMESTAMP WITH TIME ZONE NOT NULL +total_amount NUMERIC(10,2) NOT NULL +event_ts TIMESTAMP +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (book_ref) +``` + +#### `ods.tickets` +```sql +ticket_no TEXT NOT NULL +book_ref TEXT NOT NULL +passenger_id TEXT NOT NULL +passenger_name TEXT NOT NULL +is_outbound BOOLEAN +event_ts TIMESTAMP +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (ticket_no) +``` + +> В STG `tickets` распределены по `book_ref` для co-location с `bookings` (append-only, lookup не нужен). +> В ODS нужен UPSERT по бизнес-ключу `ticket_no`, поэтому распределяем по нему — иначе каждый lookup потребует redistribute motion. + +#### `ods.flights` +```sql +flight_id INTEGER NOT NULL +route_no TEXT NOT NULL +status TEXT NOT NULL +scheduled_departure TIMESTAMP WITH TIME ZONE +scheduled_arrival TIMESTAMP WITH TIME ZONE +actual_departure TIMESTAMP WITH TIME ZONE +actual_arrival TIMESTAMP WITH TIME ZONE +event_ts TIMESTAMP +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (flight_id) +``` + +#### `ods.segments` +```sql +ticket_no TEXT NOT NULL +flight_id INTEGER NOT NULL +fare_conditions TEXT NOT NULL +segment_amount NUMERIC(10,2) +event_ts TIMESTAMP +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (ticket_no) +``` + +#### `ods.boarding_passes` +```sql +ticket_no TEXT NOT NULL +flight_id INTEGER NOT NULL +seat_no TEXT NOT NULL +boarding_no INTEGER +boarding_time TIMESTAMP WITH TIME ZONE +event_ts TIMESTAMP +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (ticket_no) +``` + +Примечание: в учебном варианте не опираемся на физические `PK/FK`-constraint в Greenplum, а проверяем целостность через DQ-скрипты. + +### 4.3. Маппинг STG → ODS (колонки с изменениями) + +Большинство бизнес-колонок переносятся 1:1 с приведением типа (`TEXT → ...`). Ниже — только те, где происходит **переименование** или нетривиальное преобразование: + +| Таблица | STG колонка | ODS колонка | ODS тип | Комментарий | +|---|---|---|---|---| +| `airplanes` | `range` | `range_km` | `INTEGER` | Явное указание единиц (naming conv.) | +| `airplanes` | `speed` | `speed_kmh` | `INTEGER` | Явное указание единиц (naming conv.) | +| `routes` | `scheduled_time` | `departure_time` | `TIME` | Уточнение смысла | +| `routes` | `duration` | `duration` | `INTERVAL` | Только cast, без rename | +| `tickets` | `outbound` | `is_outbound` | `BOOLEAN` | Префикс `is_` для boolean (naming conv.) | +| `segments` | `price` | `segment_amount` | `NUMERIC(10,2)` | Уточнение: сумма сегмента, не цена билета | +| `flights` | `flight_id` | `flight_id` | `INTEGER` | Только cast TEXT → INT | +| `segments` | `flight_id` | `flight_id` | `INTEGER` | Только cast TEXT → INT | +| `boarding_passes` | `flight_id` | `flight_id` | `INTEGER` | Только cast TEXT → INT | +| все транзакционные | `event_ts` | `event_ts` | `TIMESTAMP` | Прямой перенос (канон) | +| все | `_load_id` | `_load_id` | `TEXT` | Прямой перенос (канон) | + +Пример каста с переименованием в SQL (в CTE): +```sql +s.range::INTEGER AS range_km, +s.speed::INTEGER AS speed_kmh, +s.outbound::BOOLEAN AS is_outbound, +s.price::NUMERIC(10,2) AS segment_amount +``` + +--- + +## 5) Контракт батча для ODS + +Чтобы ODS был воспроизводимым, в каждом запуске используем **один фиксированный `stg_batch_id`**. + +### 5.1. Зачем фиксировать `stg_batch_id` + +На проде ETL-оркестратор всегда явно передаёт downstream-задачам идентификатор батча, который прошёл все проверки. Это гарантирует: +- **воспроизводимость**: повторный запуск обработает тот же снимок данных; +- **изоляцию**: ODS не подхватит «сырой» батч, который ещё не прошёл DQ в STG; +- **отладку**: по `_load_id` в ODS легко найти исходные данные в STG. + +### 5.2. Как resolve `stg_batch_id` в DAG + +В DAG `bookings_to_gp_ods` первым запускается `PythonOperator`, который определяет `stg_batch_id` и кладёт его в XCom: + +```python +import logging +from airflow.operators.python import PythonOperator +from airflow.providers.postgres.hooks.postgres import PostgresHook + +log = logging.getLogger(__name__) + +GREENPLUM_CONN_ID = "greenplum_conn" + +def _resolve_stg_batch_id(**context): + """Определяем stg_batch_id: из dag_run.conf или последний согласованный snapshot-батч.""" + conf = context["dag_run"].conf or {} + stg_batch_id = conf.get("stg_batch_id") + + if not stg_batch_id: + # Берём _load_id, который присутствует во всех snapshot-таблицах STG: + # airports, airplanes, routes, seats. Это защищает от частично успешных запусков. + hook = PostgresHook(postgres_conn_id=GREENPLUM_CONN_ID) + result = hook.get_first( + ''' + WITH candidate_batches AS ( + SELECT _load_id FROM stg.airports WHERE _load_id IS NOT NULL GROUP BY _load_id + INTERSECT + SELECT _load_id FROM stg.airplanes WHERE _load_id IS NOT NULL GROUP BY _load_id + INTERSECT + SELECT _load_id FROM stg.routes WHERE _load_id IS NOT NULL GROUP BY _load_id + INTERSECT + SELECT _load_id FROM stg.seats WHERE _load_id IS NOT NULL GROUP BY _load_id + ), + batch_ready AS ( + SELECT + c._load_id, + GREATEST( + (SELECT MAX(_load_ts) FROM stg.airports a WHERE a._load_id = c._load_id), + (SELECT MAX(_load_ts) FROM stg.airplanes a WHERE a._load_id = c._load_id), + (SELECT MAX(_load_ts) FROM stg.routes r WHERE r._load_id = c._load_id), + (SELECT MAX(_load_ts) FROM stg.seats s WHERE s._load_id = c._load_id) + ) AS ready_dttm + FROM candidate_batches c + ) + SELECT _load_id + FROM batch_ready + ORDER BY ready_dttm DESC + LIMIT 1 + ''' + ) + stg_batch_id = result[0] if result and result[0] else None + + if not stg_batch_id: + raise ValueError( + "stg_batch_id не найден: передайте в conf или сначала выполните bookings_to_gp_stage" + ) + + log.info("Используем stg_batch_id = %s", stg_batch_id) + return stg_batch_id # автоматически попадёт в XCom как return_value + +resolve_batch = PythonOperator( + task_id="resolve_stg_batch_id", + python_callable=_resolve_stg_batch_id, +) +``` + +### 5.3. Как используем в SQL + +Во всех `sql/ods/*_load.sql` и `sql/ods/*_dq.sql` значение `stg_batch_id` подставляется через Jinja-шаблон: + +```sql +WHERE _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +``` + +Для читаемости в DAG можно вынести шаблон в константу: + +```python +STG_BATCH_ID = "{{ ti.xcom_pull(task_ids='resolve_stg_batch_id') }}" +``` + +В ODS записываем: +- `_load_id = ` — чтобы связать ODS-запись с STG-батчом; +- `_load_ts = now()` — фактическое время загрузки в ODS. + +Это простой и понятный паттерн: **один запуск ODS = один снимок STG-батча**. + +--- + +## 6) SQL-паттерны загрузки (SCD1 / UPSERT) + +> **Стиль SQL:** в ODS-скриптах используем CTE (Common Table Expressions) вместо вложенных подзапросов — CTE нагляднее, проще для чтения и отладки. + +### 6.1. Шаблон для справочника (пример `airports_load.sql`) + +> **Важно:** CTE действует в рамках одного SQL-statement. UPDATE и INSERT — два отдельных statement, поэтому CTE `src` дублируется в каждом. Это не ошибка, а необходимость синтаксиса SQL. + +```sql +-- Statement 1: UPDATE существующих записей (SCD1 — перезапись при изменении) +WITH src AS ( + SELECT + airport_code, + airport_name, + city, + country, + coordinates, + timezone + FROM stg.airports + WHERE _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +UPDATE ods.airports AS o +SET airport_name = s.airport_name, + city = s.city, + country = s.country, + coordinates = s.coordinates, + timezone = s.timezone, + _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + _load_ts = now() +FROM src AS s +WHERE o.airport_code = s.airport_code + AND ( + -- IS DISTINCT FROM — NULL-safe аналог <>: + -- при NULL с одной стороны <> вернёт NULL (не обновит), + -- а IS DISTINCT FROM вернёт TRUE (обновит корректно). + o.airport_name IS DISTINCT FROM s.airport_name OR + o.city IS DISTINCT FROM s.city OR + o.country IS DISTINCT FROM s.country OR + o.coordinates IS DISTINCT FROM s.coordinates OR + o.timezone IS DISTINCT FROM s.timezone + ); + +-- Statement 2: INSERT новых записей +WITH src AS ( + SELECT + airport_code, + airport_name, + city, + country, + coordinates, + timezone + FROM stg.airports + WHERE _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +INSERT INTO ods.airports ( + airport_code, airport_name, city, country, coordinates, timezone, + _load_id, _load_ts +) +SELECT + s.airport_code, s.airport_name, s.city, s.country, s.coordinates, s.timezone, + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, now() +FROM src s +WHERE NOT EXISTS ( + SELECT 1 + FROM ods.airports o + WHERE o.airport_code = s.airport_code +); + +-- Statement 3: DELETE ключей, которых нет в snapshot текущего батча +WITH src_keys AS ( + SELECT DISTINCT airport_code + FROM stg.airports + WHERE _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +DELETE FROM ods.airports o +WHERE NOT EXISTS ( + SELECT 1 + FROM src_keys s + WHERE s.airport_code = o.airport_code +); + +-- Обновляем статистику для оптимизатора запросов Greenplum +ANALYZE ods.airports; +``` + +### 6.2. Шаблон для транзакции (пример `bookings_load.sql`) + +В транзакционных таблицах в одном STG-батче может быть несколько записей с одинаковым бизнес-ключом (например, обновления). Дедуплицируем через `ROW_NUMBER()` — стандартный и явный паттерн, часто встречающийся на собеседованиях и в DE-курсах. + +```sql +-- Statement 1: UPDATE существующих записей +WITH src AS ( + SELECT + book_ref, + book_date::TIMESTAMP WITH TIME ZONE AS book_date, + total_amount::NUMERIC(10,2) AS total_amount, + event_ts, + ROW_NUMBER() OVER ( + PARTITION BY book_ref + ORDER BY event_ts DESC NULLS LAST, _load_ts DESC + ) AS rn + FROM stg.bookings + WHERE _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +UPDATE ods.bookings AS o +SET book_date = s.book_date, + total_amount = s.total_amount, + event_ts = s.event_ts, + _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + _load_ts = now() +FROM src AS s +WHERE s.rn = 1 + AND o.book_ref = s.book_ref + AND ( + o.book_date IS DISTINCT FROM s.book_date OR + o.total_amount IS DISTINCT FROM s.total_amount + ); + +-- Statement 2: INSERT новых записей +WITH src AS ( + SELECT + book_ref, + book_date::TIMESTAMP WITH TIME ZONE AS book_date, + total_amount::NUMERIC(10,2) AS total_amount, + event_ts, + ROW_NUMBER() OVER ( + PARTITION BY book_ref + ORDER BY event_ts DESC NULLS LAST, _load_ts DESC + ) AS rn + FROM stg.bookings + WHERE _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +INSERT INTO ods.bookings ( + book_ref, book_date, total_amount, event_ts, + _load_id, _load_ts +) +SELECT + s.book_ref, s.book_date, s.total_amount, s.event_ts, + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, now() +FROM src s +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 + FROM ods.bookings o + WHERE o.book_ref = s.book_ref + ); + +-- Обновляем статистику для оптимизатора запросов Greenplum +ANALYZE ods.bookings; +``` + +### 6.3. Идемпотентность паттерна + +- Для инкрементальных таблиц паттерн `UPDATE + INSERT WHERE NOT EXISTS` — **натурально идемпотентен**: + повторный запуск с тем же `stg_batch_id` не создаст дублей и не потеряет данные. +- Для snapshot-справочников идемпотентность сохраняется паттерном + `UPDATE + INSERT + DELETE not in snapshot`: повторный запуск приводит ODS к тому же состоянию. + +### 6.4. Поведение при пустом батче + +- для инкрементальных таблиц (`bookings`, `tickets`, `flights`, `segments`, `boarding_passes`) пустой батч допустим; +- для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) пустой батч считаем ошибкой. + +--- + +## 7) DQ-проверки ODS (минимум, но строго) + +Каждый DQ-скрипт должен: +- быть привязан к `stg_batch_id` (через XCom, как в load-скриптах); +- делать `RAISE EXCEPTION` при нарушении; +- давать понятную подсказку в тексте ошибки. + +### 7.1. Обязательные проверки + +1. **Нет дублей** по бизнес-ключу в ODS. + +2. **Покрытие батча:** все ключи из STG текущего батча присутствуют в ODS. + +3. **Обязательные поля** не `NULL`/не пустые. + +4. **Батч не пустой** для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`): если STG-батч оказался пустым — это ошибка (источник недоступен или PXF не работает). + +5. **Ссылочная целостность** в ODS: +- `tickets.book_ref -> bookings.book_ref` +- `flights.route_no -> routes.route_no` (упрощённая проверка: наличие `route_no` в routes, без учёта конкретной версии `validity`. Это допустимо, потому что в источнике flights ссылается на route_no, а не на конкретную версию маршрута. При downstream join (DDS) нужно будет учитывать период `validity`) +- `segments.ticket_no -> tickets.ticket_no` +- `segments.flight_id -> flights.flight_id` +- `boarding_passes (ticket_no, flight_id) -> segments (ticket_no, flight_id)` + +### 7.2. Пример проверки покрытия батча + +```sql +SELECT COUNT(*) +FROM ( + SELECT DISTINCT book_ref + FROM stg.bookings + WHERE _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) s +WHERE NOT EXISTS ( + SELECT 1 + FROM ods.bookings o + WHERE o.book_ref = s.book_ref +); +``` + +Ожидаемый результат: `0`. + +--- + +## 8) Структура файлов + +Каждый `*_ddl.sql` начинается с `CREATE SCHEMA IF NOT EXISTS ods;` (по аналогии с STG DDL). + +```text +sql/ods/ +├── airports_ddl.sql +├── airports_load.sql +├── airports_dq.sql +├── airplanes_ddl.sql +├── airplanes_load.sql +├── airplanes_dq.sql +├── routes_ddl.sql +├── routes_load.sql +├── routes_dq.sql +├── seats_ddl.sql +├── seats_load.sql +├── seats_dq.sql +├── bookings_ddl.sql +├── bookings_load.sql +├── bookings_dq.sql +├── tickets_ddl.sql +├── tickets_load.sql +├── tickets_dq.sql +├── flights_ddl.sql +├── flights_load.sql +├── flights_dq.sql +├── segments_ddl.sql +├── segments_load.sql +├── segments_dq.sql +├── boarding_passes_ddl.sql +├── boarding_passes_load.sql +└── boarding_passes_dq.sql + +sql/ddl_gp.sql (+ подключение sql/ods/*_ddl.sql) + +airflow/dags/ +├── bookings_ods_ddl.py +└── bookings_to_gp_ods.py + +docs/bookings_to_gp_ods.md +Makefile (ddl-gp включает ODS DDL) +tests/test_dags_smoke.py (+ smoke для 2 новых DAG) +``` + +--- + +## 9) DAG `bookings_to_gp_ods`: учебный граф зависимостей + +Принцип: у каждой сущности строго `load -> dq`, и только после `dq` разрешаем downstream. + +Корневой таск `resolve_stg_batch_id` определяет батч (см. секцию 5.2), после чего две параллельных ветки стартуют одновременно: + +```text +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_seats -> dq_ods_seats │ + │ + dq_ods_routes -> load_ods_flights -> dq_ods_flights │ + │ + dq_ods_flights + dq_ods_tickets -> load_ods_segments -> dq_ods_segments + │ + dq_ods_segments -> load_ods_boarding_passes -> dq_ods_boarding_passes + │ + [dq_ods_boarding_passes, dq_ods_seats] -> finish_ods_summary +``` + +Зависимости (по FK): +- `tickets` после `bookings` (FK: `book_ref`); +- `routes` после `airports` и `airplanes` (FK: `departure_airport`, `arrival_airport`, `airplane_code`); +- `seats` после `airplanes` (FK: `airplane_code`); +- `flights` после `routes` (FK: `route_no`); +- `segments` после `flights` и `tickets` (FK: `flight_id`, `ticket_no`); +- `boarding_passes` после `segments` (FK: `ticket_no`, `flight_id`). + +> Справочники (`airports`, `airplanes`) и транзакции (`bookings`) не зависят друг от друга в ODS — данные уже в STG. Поэтому они стартуют параллельно после `resolve_stg_batch_id`. + +--- + +## 10) Порядок реализации + +1. Подготовить DDL в `sql/ods/*_ddl.sql`. +2. Подключить `sql/ods/*_ddl.sql` в общий `sql/ddl_gp.sql`. +3. Использовать существующий `Makefile`-таргет `ddl-gp` для STG+ODS. +4. Создать DAG `bookings_ods_ddl.py`. +5. Реализовать `sql/ods/*_load.sql` (SCD1 UPSERT). +6. Реализовать `sql/ods/*_dq.sql`. +7. Создать DAG `bookings_to_gp_ods.py` (с параметром `stg_batch_id`). +8. Дописать smoke-тесты DAG в `tests/test_dags_smoke.py`. +9. Описать запуск и проверки в `docs/bookings_to_gp_ods.md`. + +--- + +## 11) Критерии готовности (Definition of Done) + +Готово, если: + +1. Оба новых DAG парсятся и проходят smoke-тесты (`make test`). +2. `make ddl-gp` создаёт объекты STG+ODS без ошибок. +3. Для тестового `stg_batch_id` ODS-загрузка завершается успешно. +4. Все DQ-задачи зелёные и реально валят DAG при искусственной ошибке. +5. В ODS нет дублей по бизнес-ключам. +6. Нейминг техполей ODS консистентен с учебной статьёй: `_load_id`, `_load_ts`, `event_ts`. + +--- + +## 12) Как проверять вручную + +```bash +make up +make ddl-gp # создать STG+ODS-объекты +# Trigger bookings_to_gp_stage (загрузить STG) +# Передать stg_batch_id в conf (рекомендуется) или дать ODS DAG выбрать +# последний согласованный batch автоматически. +# Trigger bookings_to_gp_ods с conf: {"stg_batch_id": "<значение>"} +make gp-psql +``` + +Проверочные SQL: + +```sql +-- 1) Дубликаты в ODS (пример bookings) +SELECT book_ref, COUNT(*) +FROM ods.bookings +GROUP BY 1 +HAVING COUNT(*) > 1; + +-- 2) Покрытие текущего STG-батча в ODS +SELECT COUNT(*) +FROM ( + SELECT DISTINCT book_ref + FROM stg.bookings + WHERE _load_id = '' +) s +WHERE NOT EXISTS ( + SELECT 1 + FROM ods.bookings o + WHERE o.book_ref = s.book_ref +); + +-- 3) Ссылочная целостность tickets -> bookings +SELECT COUNT(*) +FROM ods.tickets t +WHERE NOT EXISTS ( + SELECT 1 + FROM ods.bookings b + WHERE b.book_ref = t.book_ref +); +``` + +Ожидаемо: все три запроса возвращают `0` проблемных строк. + +--- + +## 13) Что будет следующим шагом + +После стабилизации ODS: +- строим DDS; +- показываем SCD2 на измерениях DDS (`valid_from`/`valid_to`, `created_at`/`updated_at`) по тому же неймингу, который уже знаком студентам из `dwh-modeling`. diff --git a/docs/internal/bookings_stg_design.md b/docs/design/bookings_stg_design.md similarity index 65% rename from docs/internal/bookings_stg_design.md rename to docs/design/bookings_stg_design.md index 520182a..72be507 100644 --- a/docs/internal/bookings_stg_design.md +++ b/docs/design/bookings_stg_design.md @@ -1,18 +1,20 @@ -# Дизайн STG для bookings в Greenplum (черновик) - -_Внутренний документ для учебного стенда. Перед итоговой сдачей можно объединить с основной документацией._ +# Дизайн STG для bookings в Greenplum ## 1. Цель и общий контур -- Источник: Postgres в контейнере `bookings-db`, база `demo`, таблица `bookings.bookings` (см. `docs/internal/bookings_tz.md`). +- Источник: Postgres в контейнере `bookings-db`, база `demo`, таблица `bookings.bookings` (см. [`docs/reference/bookings_tz.md`](../reference/bookings_tz.md)). - Цель: показываем путь данных от операционной БД до сырого слоя DWH в Greenplum. -- В этом документе описываем только часть `src (bookings-db) → STG (Greenplum)`. Слои ODS/DDS/DM студент проектирует сам по статье про моделирование DWH. +- В этом документе описываем часть `src (bookings-db) → STG (Greenplum)`. STG — входной слой; далее данные обрабатываются в ODS → DDS → DM (см. соответствующие design-документы). + +Примечание: в текущей версии стенда слой `stg` содержит не только `bookings`, но и остальные таблицы потока +(`tickets`, `airports`, `airplanes`, `routes`, `seats`, `flights`, `segments`, `boarding_passes`). +Ниже логика разобрана на примере `bookings`, потому что на нём проще показать принципы инкремента и батчей. Логика на уровне слоёв (по статье): - `src`: оперативная система (`bookings-db`, схема `bookings`). - `stg`: сырой слой в Greenplum, максимально близкий к источнику, без бизнес‑логики. -- дальше по заданию менти можно строить `ods`/`dds`/`dm` поверх STG. +- далее данные обрабатываются в слоях: `ods` (см. [bookings_ods_design.md](bookings_ods_design.md)), `dds` (см. [bookings_dds_design.md](bookings_dds_design.md)), `dm` (см. [bookings_dm_design.md](bookings_dm_design.md)). ## 2. Схема и таблицы в Greenplum @@ -20,8 +22,8 @@ _Внутренний документ для учебного стенда. П - Используем одну схему `stg` в Greenplum. - В этой схеме будут: - - внешняя таблица PXF для чтения из `bookings-db`; - - внутренняя таблица STG для долговременного хранения «сырых» данных. + - внешние таблицы PXF `*_ext` для чтения из `bookings-db`; + - внутренние таблицы STG `stg.*` для долговременного хранения «сырых» данных. ### 2.2. Внешняя таблица (PXF) @@ -31,7 +33,9 @@ _Внутренний документ для учебного стенда. П - можем использовать «родные» типы из `bookings.bookings` (включая даты/числа); - задача внешней таблицы — корректно читать данные из источника, не заниматься приведением типов. -DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Greenplum (примерно по шаблону из `docs/internal/pxf_bookings.md`), с `LOCATION ('pxf://bookings.bookings?PROFILE=JDBC&SERVER=bookings-db')`. +В текущей реализации аналогично созданы внешние таблицы `*_ext` и для остальных сущностей (см. `sql/stg/*_ddl.sql`). + +DDL определён в `sql/stg/bookings_ddl.sql` и подключается из `sql/ddl_gp.sql` через `\i` (применяется через `make ddl-gp`). ### 2.3. Внутренняя таблица STG @@ -44,11 +48,11 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre Технологические колонки: -- `src_created_at_ts TIMESTAMP` — дата/время из источника, приведённая к TIMESTAMP: +- `event_ts TIMESTAMP` — дата/время из источника, приведённая к TIMESTAMP: - используется как опорная колонка для инкрементальной загрузки; - - заполняется из исходной даты/времени (`created_at` или аналог). -- `load_dttm TIMESTAMP NOT NULL DEFAULT now()` — когда запись была загружена в STG. -- `batch_id TEXT NOT NULL` — идентификатор «пачки» (например, `{{ ds_nodash }}` или `run_id` Airflow). + - заполняется из опорной даты/времени, принятой для конкретной сущности (например, для `bookings` — из `book_date`). +- `_load_ts TIMESTAMP NOT NULL DEFAULT now()` — когда запись была загружена в STG. +- `_load_id TEXT NOT NULL` — идентификатор «пачки» (например, `{{ run_id }}` Airflow). - при необходимости позже можно добавить `src_system TEXT`, если появятся другие источники. Колонки‑бизнес‑ключи (`booking_id` и т.п.) храним как `TEXT`. В слое DDS позже можно будет ввести суррогатные ключи и нормализовать модель под витрины. @@ -57,20 +61,20 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre ### 3.1. Опорное поле для инкремента -- Опорная колонка: `src_created_at_ts` (внутреннее имя в STG). +- Опорная колонка: `event_ts` (внутреннее имя в STG). - Источник значения: - - берём из соответствующей колонки в `bookings.bookings` (например, `book_date`/`created_at` — будет уточнено при реализации); - - при чтении через `stg.bookings_ext` приводим к `TIMESTAMP`. + - для `bookings` используем `book_date` из `bookings.bookings` (в демо‑БД это поле естественно “шагает” по дням); + - при чтении через `stg.bookings_ext` приводим к `TIMESTAMP` и сохраняем в `stg.bookings.event_ts`. ### 3.2. Правила определения full/delta - При первом запуске, если таблица `stg.bookings` пуста: - считаем режим `full` — загружаем все строки из `stg.bookings_ext`. - При последующих запусках: - - читаем `max(src_created_at_ts)` из `stg.bookings` за все предыдущие загрузки; - - загружаем строки, где `src_created_at_ts` больше этой максимальной метки (верхняя граница по времени не задаётся). + - читаем `max(event_ts)` из `stg.bookings` за все предыдущие загрузки; + - загружаем строки, где `event_ts` больше этой максимальной метки (верхняя граница по времени не задаётся). -Таким образом, вся логика инкремента «замкнута» на один техно‑столбец `src_created_at_ts`, который студент потом сможет использовать и на следующих слоях (например, в CDC‑логике). +Таким образом, вся логика инкремента «замкнута» на один техно‑столбец `event_ts`, который студент потом сможет использовать и на следующих слоях (например, в CDC‑логике). ## 4. DAG’и Airflow (логика на уровне задач) @@ -79,8 +83,8 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre - `dag_id`: `bookings_stg_ddl` (реализован в `airflow/dags/bookings_stg_ddl.py`). - Назначение: один раз (или при изменении схемы) создать необходимые объекты в Greenplum: - схему `stg` (если её ещё нет); - - внешнюю таблицу `stg.bookings_ext` (PXF → `bookings-db`); - - внутреннюю таблицу `stg.bookings` с текстовыми колонками и тех.полями. + - внешние таблицы `*_ext` и внутренние таблицы слоя `stg` для всех сущностей потока + (см. `sql/stg/*_ddl.sql`). - Этот DAG не загружает данные, только подготавливает структуру. - Вся DDL‑логика (CREATE/ALTER/DROP) сосредоточена здесь; рабочие DAG’и занимаются только DML (INSERT/SELECT). @@ -88,7 +92,7 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre - `dag_id`: `bookings_to_gp_stage`. - Основные параметры: - - `batch_id` (в текущей реализации `{{ run_id }}`) — метка батча, которая попадает в `stg.bookings.batch_id`; + - `_load_id` (в текущей реализации `{{ run_id }}`) — метка батча, которая попадает в `stg.bookings._load_id`; - подключения: - `bookings_db_conn_id` — Airflow connection к `bookings-db` (в коде DAG — `BOOKINGS_CONN_ID = "bookings_db"`); - `greenplum_conn_id` — Airflow connection к Greenplum (`GREENPLUM_CONN_ID = "greenplum_conn"`). @@ -104,29 +108,30 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre 2. `load_bookings_to_stg` - PostgresOperator к Greenplum; - выполняет скрипт `/sql/stg/bookings_load.sql`; - - внутри SQL считается `max(src_created_at_ts)` по «старым» батчам и по нему строится окно инкремента: + - внутри SQL считается `max(event_ts)` по «старым» батчам и по нему строится окно инкремента: - первая загрузка (full) — берём все строки из `stg.bookings_ext`; - последующие загрузки — берём только записи, где `book_date` больше предыдущего максимума (верхняя граница по дате не задаётся явно); - - при вставке заполняются тех.колонки `src_created_at_ts`, `load_dttm`, `batch_id`. + - при вставке заполняются тех.колонки `event_ts`, `_load_ts`, `_load_id`. 3. `check_row_counts` - PostgresOperator к Greenplum; - выполняет скрипт `/sql/stg/bookings_dq.sql`; - скрипт заново считает окно инкремента по тем же правилам, что и загрузка, и сравнивает: - количество строк в `stg.bookings_ext` с `book_date` позже «старого» максимума, - - количество строк в `stg.bookings` для текущего `batch_id`; + - количество строк в `stg.bookings` для текущего `_load_id`; - при расхождении выполняет `RAISE EXCEPTION` с понятным текстом ошибки. -4. `finish_summary` +4. Далее — загрузка и DQ для остальных таблиц потока (tickets, справочники, транзакции). +5. `finish_summary` - PythonOperator, который логирует итог выполнения DAG и напоминает, где смотреть детальные логи. Таким образом, вся бизнес‑логика инкремента и проверок живёт в SQL‑скриптах, а DAG отвечает за оркестрацию и подключение к нужным БД. Для менти это хороший пример разделения ответственности между SQL и Python. ## 5. Связь с остальными документами -- `docs/internal/bookings_tz.md` — как готовится и генерируется источник `bookings-db`. -- `docs/internal/pxf_bookings.md` — детали настройки PXF и внешней таблицы для чтения из `bookings-db`. +- [`docs/reference/bookings_tz.md`](../reference/bookings_tz.md) — как готовится и генерируется источник `bookings-db`. +- [`docs/reference/pxf_bookings.md`](../reference/pxf_bookings.md) — детали настройки PXF и внешней таблицы для чтения из `bookings-db`. - `sql/stg/bookings_ddl.sql` — DDL для схемы `stg` и таблиц `stg.bookings_ext` / `stg.bookings` (подключается из `sql/ddl_gp.sql` и применяется через `make ddl-gp`). -Дальнейшая модель DWH (слои ODS/DDS/DM, факт/измерения, SCD) должна быть спроектирована студентом по статье о моделировании данных, используя `stg.bookings` как входной слой. +Дальнейшая обработка данных описана в design-документах ODS/DDS/DM (см. раздел 5). ## 6. Требования к читаемости и комментариям diff --git a/docs/design/db_schema.md b/docs/design/db_schema.md new file mode 100644 index 0000000..400290a --- /dev/null +++ b/docs/design/db_schema.md @@ -0,0 +1,314 @@ +# Схема БД 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;`). Данные появятся после реализации заданий. + +Архитектура хранилища данных (DWH) для учебного проекта Airflow + Greenplum. +Источник — демо-БД `bookings` (Postgres). Документ даёт цельный взгляд «сверху»; +детали реализации — в дизайн-документах слоёв. + +--- + +## Полная схема потоков данных (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_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 +``` + +--- + +## Сводка объектов по слоям + +### STG (Staging) + +Сырые данные из источника, все бизнес-поля как TEXT. Хранение: AO Row (zstd). +Служебные поля: `event_ts`, `_load_ts`, `_load_id`. + +| Таблица | Источник (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` | + +Детали: [`bookings_stg_design.md`](bookings_stg_design.md) + +### ODS (Operational Data Store) + +Очищенные данные с корректными типами. Справочники: AO Row (TRUNCATE+INSERT). +Транзакции: Heap (SCD1 UPSERT). Служебные поля: `_load_id`, `_load_ts`, +`event_ts` (для транзакционных таблиц). + +| Таблица | Стратегия | 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 | Без преобразований | + +Детали: [`bookings_ods_design.md`](bookings_ods_design.md) + +### DDS (Detailed Data Store — Star Schema) + +Измерения с суррогатными ключами. Факт в центре звезды. +SK генерация: `MAX(sk) + ROW_NUMBER()` (безопасно при `concurrency=1`). + +#### Измерения + +| Измерение | 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` | + +#### Факт + +`dds.fact_flight_sales` — зерно: 1 строка = 1 сегмент билета (`ticket_no` + `flight_id`). + +| FK | Источник | Примечание | +|----|----------|------------| +| `calendar_sk` | `dim_calendar` | Дата вылета | +| `departure_airport_sk` | `dim_airports` | Аэропорт вылета | +| `arrival_airport_sk` | `dim_airports` | Аэропорт прилёта | +| `airplane_sk` | `dim_airplanes` | Самолёт | +| `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`. + +Детали: [`bookings_dds_design.md`](bookings_dds_design.md) + +### DM (Data Marts — Витрины) + +Аналитические витрины поверх DDS. Каждая отвечает на конкретный бизнес-вопрос. + +| Витрина | Бизнес-вопрос | Зерно | Стратегия | 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 | + +Детали: [`bookings_dm_design.md`](bookings_dm_design.md) + +--- + +## Ключевые договорённости + +- **Нейминг полей**: [`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-специфика) + +--- + +## Обучающие материалы + +### Глоссарий + +| Термин | Объяснение | +|--------|-----------| +| **Зерно факта (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) — ТЗ от аналитика (курсовое задание) diff --git a/docs/design/naming_conventions.md b/docs/design/naming_conventions.md new file mode 100644 index 0000000..ecabbf9 --- /dev/null +++ b/docs/design/naming_conventions.md @@ -0,0 +1,66 @@ +# Конвенции Нейминга DWH (Единый Источник) + +> Статус: активный стандарт для новых реализаций в этом репозитории. +> +> Основа: учебные материалы `de-roadmap/dwh-modeling`. + +## Зачем этот документ + +Чтобы имена полей не «плыли» между слоями, DAG и SQL-скриптами: +- студенты видят один и тот же словарь во всех задачах; +- новые реализации (ODS/DDS/DM) не расходятся с тем, как уже учили на `dwh-modeling`; +- ревью становится проще: сразу видно, где отклонение от стандарта. + +## 1. Базовые правила + +- Имена колонок: `snake_case`, на английском. +- Бизнес-ключи источника не переименовываем без необходимости (`book_ref`, `ticket_no`, `route_no`). +- Булевы поля начинаются с `is_` (`is_outbound`, `is_boarded`). +- Денежные/количественные поля называем явно (`total_amount`, `segment_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 при событийной природе данных | +| `created_at` | `TIMESTAMP NOT NULL` | Когда строка создана в таблице слоя | DDS/DM, где есть lifecycle строки | +| `updated_at` | `TIMESTAMP NOT NULL` | Когда строка обновлена в таблице слоя | DDS/DM, где есть UPDATE | +| `valid_from` | `DATE NOT NULL` (базовый трек) | Начало действия версии SCD2 | DDS SCD2 | +| `valid_to` | `DATE` | Конец действия версии SCD2 (`NULL` = current) | DDS SCD2 | +| `hashdiff` | `TEXT NOT NULL` | Хэш атрибутов версии для детекта изменений | DDS SCD2 | + +## 3. Ключи в DDS + +- Бизнес-ключ измерения: суффикс `_bk` (`customer_bk`, `airport_bk`). +- Суррогатный ключ измерения: суффикс `_sk` (`customer_sk`, `airport_sk`). + +## 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-скрипте. + +## 5. Применение по слоям + +### STG + +- Используем канон `_load_id`, `_load_ts`, `event_ts` для всех таблиц. + +### ODS + +- Используем канон `_load_id`, `_load_ts`, `event_ts`. +- Базовый эталон ODS в этом стенде: SCD Type 1 (current state + UPSERT). + +### DDS + +- Для SCD2 используем: + - `valid_from`, `valid_to`, `hashdiff`, `created_at`, `updated_at`. +- Интервалы считаем как `[valid_from, valid_to)`, current-версия: `valid_to IS NULL`. + +## 6. Что проверяем в ревью + +- Нет новых техполей-синнонимов вроде `loaded_at`, `ingested_at`, `batch_key`, если уже есть канон. +- Нет смешивания `event_ts` и `_load_ts` в одном смысле. +- В SCD2 не используются альтернативы `dw_start_date/dw_end_date`, если в проекте принят `valid_from/valid_to`. diff --git a/docs/e2e-etl-test-protocol.md b/docs/e2e-etl-test-protocol.md new file mode 100644 index 0000000..582684f --- /dev/null +++ b/docs/e2e-etl-test-protocol.md @@ -0,0 +1,184 @@ +# Протокол сквозного (E2E) тестирования ETL + +Этот документ описывает процедуру полной проверки цепочки ETL: `STG -> ODS -> DDS -> DM`. +Цель теста — убедиться в корректности инкрементальной загрузки, работы паттерна `Temporary Table` и механизмов `HWM`. + +**Управление DAG'ами — только через Airflow UI или REST API.** +Не используйте CLI-команды внутри контейнера (`docker exec ... airflow dags ...`) — они могут молча не сработать (например, `unpause` не снимает паузу). REST API работает надёжно и приближает опыт к реальной боевой эксплуатации. + +--- + +## 1. Подготовка окружения + +Убедитесь, что все сервисы запущены и генератор инициализирован. + +```bash +make up +make bookings-init +``` + +**Доступ к API:** +В `docker-compose.yml` включена базовая аутентификация (`basic_auth`). +Для curl-запросов используйте учетные данные из вашего `.env` файла (переменные `AIRFLOW_USER` и `AIRFLOW_PASSWORD`, по умолчанию `admin:admin`). + +- **UI:** `http://localhost:8080` +- **API Endpoint:** `http://localhost:8080/api/v1` + +--- + +## 2. Очистка данных (Reset) + +Перед началом теста необходимо полностью очистить все слои DWH. + +```bash +make dwh-truncate +``` +*(Если вы меняли DDL, лучше полностью пересоздать схемы: `make gp-psql -c "DROP SCHEMA IF EXISTS ods CASCADE; DROP SCHEMA IF EXISTS dds CASCADE; DROP SCHEMA IF EXISTS dm CASCADE; CREATE SCHEMA ods; CREATE SCHEMA dds; CREATE SCHEMA dm;"`)* + +--- + +## 3. Этап 1: Создание схем и Загрузка за первый день (Initial Load) + +Рекомендуется запускать слои последовательно, дожидаясь завершения предыдущего. + +### Создание DDL +Откройте **Airflow UI** (`http://localhost:8080`) и нажмите кнопку **▶ Play -> Trigger DAG** для DDL-дагов: +1. `bookings_stg_ddl` +2. `bookings_ods_ddl` +3. `bookings_dds_ddl` +4. `bookings_dm_ddl` + +### Запуск пайплайна (Day 1) +После успешного создания таблиц, запустите DAG загрузки `bookings_to_gp_stage`. + +**Через UI:** откройте http://localhost:8080, **снимите DAG с паузы** (переключатель слева от названия) и нажмите **▶ Play -> Trigger DAG**. + +**Через REST API (рекомендуется для автоматизации и агентов):** + +**Важно:** при старте стенда все DAG-и находятся на паузе. Каждый DAG перед первым запуском нужно «разморозить» (unpause), иначе запуск зависнет в статусе `queued`. Для каждого DAG требуется два шага: **1) unpause, 2) trigger**. + +```bash +# 1. Снятие с паузы (обязательно перед первым запуском!) +curl -s -X PATCH "http://localhost:8080/api/v1/dags/bookings_to_gp_stage" \ +--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \ +-H "Content-Type: application/json" \ +-d '{"is_paused": false}' + +# 2. Запуск +curl -s -X POST "http://localhost:8080/api/v1/dags/bookings_to_gp_stage/dagRuns" \ +--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \ +-H "Content-Type: application/json" \ +-d '{}' +``` + +### Проверка статуса +Дождитесь, пока DAG перейдет в статус `success` (следите в UI или опрашивайте через API): +```bash +curl -s "http://localhost:8080/api/v1/dags/bookings_to_gp_stage/dagRuns?order_by=-execution_date&limit=1" \ +--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" | python3 -c "import sys,json; print(json.load(sys.stdin)['dag_runs'][0]['state'])" +``` + +### Запуск остальных слоёв +Поочерёдно запускайте каждый следующий слой **по той же схеме: unpause + trigger** (замените ``): +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 +Подставьте ваш `` (например, `manual__2026-03-03T...`) и имя упавшей таски: +```bash +curl -s "http://localhost:8080/api/v1/dags/bookings_to_gp_ods/dagRuns//taskInstances//logs/1" \ +--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" +``` + +### Перезапуск задачи (Clear) +1. Прочитайте ошибку в логах. +2. Исправьте SQL-файл локально на хосте. +3. Очистите состояние упавших задач (`only_failed: true`) в конкретном запуске, передав ваш ``: + +```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": "" +}' +``` +После этого планировщик подхватит обновленный 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"`) интерпретируются как идентификаторы колонок. diff --git a/docs/internal/bookings_tz.md b/docs/internal/bookings_tz.md deleted file mode 100644 index 691f63e..0000000 --- a/docs/internal/bookings_tz.md +++ /dev/null @@ -1,25 +0,0 @@ -# Временное ТЗ по блоку bookings (для текущей разработки) - -_Этот файл внутренний, удалить перед итоговой сдачей._ - -- Контейнер `bookings-db` — отдельный сервис Postgres из `docker-compose.yml`, база по умолчанию `demo` (из upstream demodb), без переименований. -- Доступ снаружи не блокируем (порт `5434` по умолчанию), чтобы позже читать через PXF и подключаться из Greenplum. -- Инициализация (`make bookings-init`): поднимает контейнер, клонирует demodb с закреплённым коммитом, накладывает патчи (`engine`: `jobs=1` синхронно + `busy()` игнорирует свой pid; `install.sql`: `DROP DATABASE IF EXISTS`), ждёт `pg_isready`, ставит `gen.connstr` и GUC `bookings.start_date/init_days/jobs`, затем запускает `/bookings/generate_next_day.sql` через `psql -f`. Значения по умолчанию: стартовая дата 2017-01-01, `init_days=1`, `jobs=1`. -- Генерация следующего дня: `make bookings-generate-day` прогоняет тот же SQL (читает GUC, вызывает `generate/continue`, ждёт `busy()`, закрывает dblink). При `jobs=1` всё синхронно, без dblink. -- Исходники demodb: клонируем по требованию с фиксированным хешем, кладём в `bookings/demodb/` (в `.gitignore`), патчи лежат в `bookings/patches/` и применяются автоматически. -- Документация: в README описаны команды (`bookings-init`, проверка данных, генерация дня), параметры `.env`; настройка PXF/ETL — следующий этап. - -## Текущее состояние -- `Makefile` теперь автоматически применяет патчи (`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=1`, `BOOKINGS_JOBS=1`. При `jobs=1` генерация идёт синхронно без dblink, `busy()` не учитывает текущую сессию. -- `.env.example`/README обновлены под новые дефолты; каталог `bookings/demodb/` в `.gitignore`. -- Патчи лежат в `bookings/patches/` и накладываются при `bookings-clone-demodb`. - -## Текущее состояние тестов/проблем -- Чистый прогон `make bookings-init` (после `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. diff --git a/docs/plans/2026-03-12_main-solution-split.md b/docs/plans/2026-03-12_main-solution-split.md new file mode 100644 index 0000000..911332c --- /dev/null +++ b/docs/plans/2026-03-12_main-solution-split.md @@ -0,0 +1,342 @@ +# Plan: Подготовка main + ветка solution (Этап 4) + +> Дата: 2026-03-12 | Версия: 4 (после третьего ревью Codex) +> Ветка: chore/bookings-etl → main → solution +> Спецификации кода: `docs/archive/2026-03-11_routes-to-reference.md` (п.2–5, п.8, п.10–11) + +--- + +## 1. Мотивация + +### Зачем раскладка по веткам + +Сейчас весь код (эталон + студенческие задания) живёт в ветке `chore/bookings-etl`. +Студент должен получить **частично реализованный пайплайн**: эталонный вертикальный +срез работает «из коробки», а студенческие файлы — заглушки (`SELECT 1; -- TODO`). +Полная реализация доступна в ветке `solution` для самопроверки. + +### Почему именно такой порядок + +**Рассмотренные варианты:** + +| # | Схема | Проблема | +|---|-------|----------| +| A | Создать solution от bookings-etl, потом на main — cherry-pick + заглушки | Грязные cherry-pick'и, main временно «не main» | +| B | Мерж в main, заглушки на main, solution = cherry-pick эталона обратно | main в какой-то момент содержит полный код, потом — заглушки; solution — через cherry-pick | +| **C** | **Мерж в main → общие правки → ветка solution (снимок) → main-only правки** | **Выбран** | + +**Почему вариант C:** + +- **main всегда остаётся main.** Нет момента, когда main «подменяется» другой веткой. + Любые CI/CD, ссылки, клонирования — работают непрерывно. +- **Простая линейная история.** Мерж → общие правки → бранч → main-only коммиты. + Никаких cherry-pick'ов. +- **solution — архивный снимок.** Не ожидается активная разработка. Студент сверяется + с ним, а не мержит. +- **Общие правки до ветвления.** Документация, актуальная для обеих веток, + обновляется *до* создания solution. Solution получает её автоматически. + +### Стратегия синхронизации веток + +После раскладки `main` (заглушки) и `solution` (полный код) расходятся. + +**Главный принцип: solution — source of truth.** + +Все изменения начинаются на solution (или feature-ветке от solution), +затем портируются в main. Направление потока: **solution → main**. + +Файлы делятся на три категории: + +| Категория | Примеры | Правило | +|-----------|---------|---------| +| **Общий код** | STG SQL, docker, Makefile, инфраструктура | Фиксим на solution, cherry-pick в main | +| **Общая документация** | README, TESTING, stack.md, naming_conventions | Фиксим на solution, cherry-pick в main | +| **Branch-specific** | заглушки load/dq, fact_flight_sales_dq (ослабленный), routes_dq (без RI), ODS DAG (без airplanes→routes), branch-specific формулировки в design docs | Фиксим на нужной ветке. Конфликтов нет — содержимое файлов разное | + +**Исключение:** main-only правка (опечатка в заглушке, битая ссылка +в main-only документе) — правим прямо на main, solution не трогаем. + +**Главное правило: никогда не мержим main → solution целиком.** + +> Детальный протокол синхронизации — открытый вопрос, см. секцию 3. + +--- + +## 2. Порядок выполнения + +### Шаг 1. PR chore/bookings-etl → main + +- Создать PR, ревью +- Мерж (squash или обычный — на усмотрение) +- После мержа: `git checkout main && git pull` + +### Шаг 2. Общие правки на main (до ветвления solution) + +> **Bootstrap-исключение:** стратегия синхронизации (секция 1) определяет +> solution как source of truth с потоком solution → main. Но при первичной +> раскладке solution ещё не существует — общие правки делаются на main, +> и solution наследует их при ветвлении (шаг 3). После создания solution +> действует штатный протокол. + +Эти изменения отражают код, уже изменённый на bookings-etl +(fact_flight_sales_load.sql использует ods.routes). Документация должна +соответствовать коду на **обеих** ветках. + +#### 2.1. Документация (общая для обеих веток) + +- `docs/design/bookings_dds_design.md` — обновить описание fact lookup + (airports через ods.routes, airplane через dim_routes point-in-time) +- `docs/bookings_to_gp_dds.md` — обновить описание DDS lookup +- `docs/bookings_to_gp_ods.md` — обновить описание ODS +- `docs/design/db_schema.md` — пометить STG + ods.routes как эталон + +#### 2.2. TODO.md — переписать + +Текущий текст Этапа 4 в TODO.md описывает устаревшую стратегию +(удалить routes из STG, убирать `\i` из ddl_gp.sql). Нужно обновить, +чтобы на solution осталась корректная история: + +- Отметить этапы 2, 3 выполненными (✅) +- **Переписать** текст этапа 4 (не просто поставить галочку) — привести + в соответствие с фактической стратегией (данный план) +- Отметить этап 4 выполненным после завершения + +### Шаг 3. Создать ветку solution + +```bash +git checkout main +git checkout -b solution +git push -u origin solution +``` + +Solution получает: полный эталонный код + актуальную общую документацию + +все design docs, plans, archive, TODO.md — полный контекст для мейнтейнера. + +### Шаг 4. Main-only правки (заглушки, ослабление DQ) + +Работаем на main (или на feature-ветке → PR в main). + +#### 4.1. Код: fact_flight_sales_dq.sql — ослабить student SK + +Спецификация: архивный план, п.2. + +- `passenger_sk IS NULL` → `RAISE NOTICE` (было `RAISE EXCEPTION`) +- Разделить route-related блок: airport_sk (порог 1%, EXCEPTION) vs + route_sk/airplane_sk (NOTICE only) +- Добавить учебный комментарий + +#### 4.2. Код: routes_dq.sql — закомментировать RI airplane + +Спецификация: архивный план, п.3. + +- **Закомментировать** (не удалять) блок RI `airplane_code → ods.airplanes` +- Добавить комментарий: + +```sql +-- Проверка RI airplane_code → ods.airplanes закомментирована, +-- т.к. таблица ods.airplanes реализуется студентом. +-- После реализации — раскомментируйте этот блок. +-- Полную версию см. в ветке solution. +``` + +#### 4.3. Код: ODS DAG — убрать зависимость airplanes → routes + +Спецификация: архивный план, п.4. + +- `[dq_ods_airports, dq_ods_airplanes] >> load_ods_routes` + → `dq_ods_airports >> load_ods_routes` + +#### 4.4. Заглушки: студенческие файлы + +Спецификация: архивный план, п.5. + +| Слой | Файлы (load + dq) | DDL | +|------|--------------------|-----| +| ODS | airplanes, seats | Оставить (таблица нужна) | +| DDS | dim_routes, dim_passengers, dim_airplanes | Оставить (нужен для LEFT JOIN) | +| DM | airport_traffic, route_performance, monthly_overview, passenger_loyalty | Оставить | + +Формат заглушки load: +```sql +-- TODO: реализуйте загрузку (см. ТЗ в docs/assignment/analyst_spec.md) +-- Эталонную реализацию можно найти в ветке solution. +SELECT 1; +``` + +Формат заглушки dq: +```sql +-- TODO: реализуйте проверки качества данных +-- Эталонную реализацию можно найти в ветке solution. +SELECT 1; +``` + +#### 4.5. Тесты + +Спецификация: архивный план, п.10. + +- `test_dags_smoke.py`: убрать assert барьера `dq_ods_airplanes → dq_ods_routes` +- `test_ods_sql_contract.py`: `SNAPSHOT_ENTITIES = ("airports", "routes")` + (убрать airplanes, seats — их load/dq теперь заглушки) + +#### 4.6. Документация (main-specific) + +- `docs/design/bookings_ods_design.md` — пометить airplanes/seats как студенческие, + убрать зависимость routes от airplanes, скорректировать DQ-контракт +- `docs/design/bookings_dds_design.md` — обновить DQ-описание: + student SK не блокируют (NOTICE), airport_sk — порог 1% +- `docs/bookings_to_gp_dds.md` — обновить: student SK будут NULL на main +- `docs/reference/qa-plan.md` — уточнить: ods.airplanes и ods.seats + пусты by design на main + +### Шаг 5. Очистка docs на main (после всех правок) + +> Этот шаг выполняется **после** шагов 2–4, потому что: +> - solution уже создан (шаг 3) и сохранил все файлы +> - план и архивный справочник были доступны во время работы (шаг 4) + +#### 5.1. Удалить с main (остаётся на solution) + +| Файл/каталог | Почему не нужен студенту | +|--------------|------------------------| +| `docs/plans/` (весь каталог) | Планы разработки — внутренняя кухня | +| `docs/archive/` (весь каталог) | Архив планов — внутренняя кухня | +| `docs/design/PRD.md` | Продуктовые требования — внутренний документ | +| `docs/design/assignment_design.md` | Мета-дизайн задания (для авторов курса, не для студентов) | +| `docs/reference/bookings_generation_benchmark.md` | Бенчмарки генерации — отладочная информация | +| `docs/reference/bookings_tz.md` | Заметки о таймзонах — отладочная информация | +| `docs/reference/pxf_bookings.md` | PXF-конфигурация — внутренняя отладка | +| `docs/agent-dag-testing.md` | Инструкция для AI-агентов по тестированию | +| `docs/e2e-etl-test-protocol.md` | E2E-протокол — внутреннее тестирование | +| `TODO.md` | Таск-лист мейнтейнера | + +#### 5.2. Что остаётся на main (полезно студенту) + +| Файл | Зачем студенту | +|------|---------------| +| `README.md` | Установка, запуск, структура проекта | +| `TESTING.md` | Как проверять свою работу | +| `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` | Если студент использует AI-помощников | +| `docs/README.md` | Навигация по документации | +| `docs/stack.md` | Стек технологий — контекст | +| `docs/dag_execution_order.md` | Порядок запуска DAG'ов | +| `docs/bookings_to_gp_stage.md` | Описание STG — эталонный код для изучения | +| `docs/bookings_to_gp_ods.md` | Описание ODS | +| `docs/bookings_to_gp_dds.md` | Описание DDS | +| `docs/bookings_to_gp_dm.md` | Описание DM | +| `docs/design/naming_conventions.md` | Нейминг полей — нужен для DDL/SQL | +| `docs/design/db_schema.md` | Схема БД — справочник | +| `docs/design/bookings_stg_design.md` | Дизайн STG — эталон для изучения | +| `docs/design/bookings_ods_design.md` | Дизайн ODS — нужен для задания | +| `docs/design/bookings_dds_design.md` | Дизайн DDS — нужен для задания | +| `docs/design/bookings_dm_design.md` | Дизайн DM — нужен для задания | +| `docs/assignment/` | Задание (analyst_spec.md) | +| `docs/reference/qa-plan.md` | QA-чеклист — полезен для самопроверки | +| `docs/reference/bookings_db_issues.md` | Известные проблемы — чтобы студент не тратил время на отладку | + +#### 5.3. AGENTS.md — адаптировать для main + +`AGENTS.md` содержит карту проекта и ссылки, которые побьются после cleanup. +Нужно обновить, а не просто добавить одну строку: + +- **Карта проекта (секция 2):** убрать упоминания `docs/plans/`, `docs/archive/`. + Добавить: «Полные дизайн-документы (PRD, assignment_design, планы) — в ветке solution» +- **Тестирование:** убрать ссылку на `docs/agent-dag-testing.md` (файл удалён) +- **Прочие ссылки:** проверить, что все пути в AGENTS.md ведут на существующие файлы + +#### 5.4. Link audit — полная проверка ссылок + +Проверить **все** ссылки во **всех** оставшихся на main markdown-файлах, +а не только ссылки на удалённые файлы. В документации уже есть битые ссылки +(напр. `docs/README.md` → `design/architecture_review.md`, файл в архиве). + +**Метод:** + +```bash +# 1. Найти все markdown-ссылки в оставшихся файлах +rg -o '\[.*?\]\([^)]+\.md[^)]*\)' docs/ README.md TESTING.md AGENTS.md + +# 2. Проверить существование каждого целевого файла +# 3. Починить: удалить ссылку, обновить путь, или заменить на «см. ветку solution» +``` + +Известные проблемы (минимум): + +- `docs/README.md` — ссылки на plans/, archive/, PRD, assignment_design, architecture_review +- `docs/assignment/README.md` — возможные ссылки на assignment_design +- `docs/design/bookings_stg_design.md` — ссылки на внутренние reference +- `docs/bookings_to_gp_stage.md` — ссылки на reference +- `docs/stack.md` — ссылки на reference +- `docs/design/db_schema.md` — ссылки на PRD, assignment_design +- `README.md` — ссылки на TODO.md, PRD +- `AGENTS.md` — покрыто шагом 5.3 + +### Шаг 6. Верификация main + +```bash +make test # smoke-тесты и контракты +make lint # линтинг +``` + +При поднятом стенде: +1. `make up` → `make ddl-gp` +2. Запустить STG → ODS → DDS → DM DAG'и +3. Проверить: `sales_report` содержит данные с корректными аэропортами +4. Проверить: студенческие ODS/DDS-таблицы пусты +5. Fact DQ проходит (student SK = NULL, но DQ не блокирует) + +### Шаг 7. Верификация solution + +```bash +git checkout solution +make test +``` + +Убедиться, что полный пайплайн работает без изменений (это снимок +проверенного chore/bookings-etl + общие doc-правки). + +--- + +## 3. Открытый вопрос: протокол синхронизации веток + +> Не блокирует Этап 4. Доработать после раскладки, когда появится конкретный +> опыт (например, при первом PXF-задании). + +### Предварительное решение: solution как source of truth + +Все изменения (новые задания, баг-фиксы, доработки) начинаются на solution +или на feature-ветке от solution. Main — производная. + +``` +solution ← feat/new-task (разработка + тесты) + ↓ merge +solution (полный эталон, всегда рабочий) + ↓ порт +main (cherry-pick общего + заглушки) +``` + +**Поток по умолчанию:** solution → main (одно направление). + +**Исключение (редко):** main-only правка (опечатка в заглушке, битая ссылка +в main-only документе) — правим прямо на main, solution не трогаем. + +### Что нужно доработать + +- Как именно выглядит «порт в main»: cherry-pick, ручной перенос, чеклист? +- Что делать при конфликтах cherry-pick? +- Нужен ли реестр known-different файлов (заглушки vs реализации) для + автоматической проверки синхронизации? +- Нужен ли периодический `git diff main..solution -- ` для + обнаружения расхождений? + +--- + +## 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) | diff --git a/docs/plans/README.md b/docs/plans/README.md new file mode 100644 index 0000000..4098055 --- /dev/null +++ b/docs/plans/README.md @@ -0,0 +1,3 @@ +# Планы работ + +Активные планы. После выполнения переносятся в [`archive/`](../archive/). diff --git a/docs/reference/bookings_db_issues.md b/docs/reference/bookings_db_issues.md new file mode 100644 index 0000000..cf7d526 --- /dev/null +++ b/docs/reference/bookings_db_issues.md @@ -0,0 +1,143 @@ +# Проблемы bookings-db (demodb) + +> Дата обнаружения: 2026-03-08 +> Зафиксированный коммит demodb: `866e56f7fe54596a1d2a88f5f32f4aa3b2698121` + +--- + +## 1. gen.connstr без credentials — генерация обрывается + +### Симптом + +После `make bookings-generate` (генерация с нуля) таблицы в demo-базе существуют, но **пустые**. +Повторные `make bookings-generate-day` тоже дают 0 строк. + +### Корневая причина + +`install.sql` из demodb хардкодит: + +```sql +ALTER DATABASE demo SET gen.connstr = 'dbname=demo'; +``` + +Без `user` и `password`. Makefile устанавливает правильный connstr +(с `user=bookings password=bookings`) **после** `install.sql`, но при повторном +`make bookings-generate` порядок тот же: install.sql перезаписывает → Makefile +восстанавливает. Если что-то идёт не так между этими шагами, connstr остаётся +без credentials. + +Далее цепочка: +1. `process_queue()` обрабатывает события (BOOKING, FLIGHT, VACUUM и т.д.) +2. Событие VACUUM вызывает `dblink_exec(gen.connstr, 'VACUUM ANALYZE')` +3. dblink без user → подключается как `postgres` → `FATAL: role "postgres" does not exist` +4. `process_queue()` ловит ошибку, логирует и **прекращает работу** +5. Генерация обрывается на 1-2 модельных днях → данных почти нет + +### Диагностика + +```sql +-- В psql к demo-базе: +SHOW gen.connstr; +-- Если "dbname=demo" без user → проблема подтверждена + +SELECT * FROM gen.log ORDER BY at DESC LIMIT 5; +-- Искать: "could not establish connection" / "role postgres does not exist" +``` + +### Воркэраунд + +```sql +-- Выполнить на bookings-db ПОСЛЕ install.sql (актуально при make bookings-generate): +ALTER DATABASE demo SET gen.connstr = 'dbname=demo user=bookings password=bookings'; +-- Затем переподключиться к demo и запустить генерацию заново. +``` + +### Правильное решение + +Один из вариантов: +- Патч `install.sql`, подставляющий credentials из ENV +- Обновление demodb до версии, где это исправлено + +--- + +## 2. BOOKINGS_INIT_DAYS=1 — недостаточно для генерации данных + +### Симптом + +Даже при исправленном connstr, с `BOOKINGS_INIT_DAYS=1` (дефолт в Makefile) +`bookings.bookings` остаётся пустой. + +### Корневая причина + +Генератор demodb использует константы: +- `ROUTES_DURATION() = 1 month` — маршруты строятся на месяц вперёд +- `ROUTES_TAKE_EFFECT() = 2 months` — маршруты начинают действовать через 2 месяца + +При `start_date=2017-01-01` и `init_days=1`: +- `end_date = 2017-01-02` +- Генератор успевает только INIT + BUILD ROUTES (маршруты на февраль-март) +- До создания бронирований не доходит → 0 строк + +Проверено: +| init_days | bookings | boarding_passes | Время генерации | +|-----------|----------|-----------------|-----------------| +| 1 | 0 | 0 | ~1 сек | +| 30 | ~30 000 | 0 | ~1-2 мин | +| 90 | ? | ? (ожидаем >0) | ~10+ мин | + +--- + +## 3. boarding_passes всегда пустая + +### Симптом + +Таблица `bookings.boarding_passes` ни разу не содержала данных. + +### Корневая причина + +Boarding passes создаются при событиях CHECK-IN и BOARDING, которые +генерируются при REGISTRATION рейса (~24ч до вылета). Рейсы начинаются +с 2017-02-01. Цепочка: + +1. Нужны маршруты → появляются при init_days ≥ 1 +2. Нужны рейсы → появляются при init_days ≥ 30 +3. Нужны бронирования/сегменты → появляются при init_days ≥ 30 +4. Нужна регистрация (за ~24ч до вылета) → init_days ≥ ~60 +5. Нужны boarding events → init_days ≥ ~60 + +При init_days ≤ 30 генератор не доходит до дат регистрации. +При init_days ≥ 60 баг #1 (connstr) убивал генерацию раньше. + +Возможно, есть и баг в самой версии demodb — требует проверки после +обновления. + +--- + +## 4. UX: время генерации + +Генерация данных занимает значительное время: + +| init_days | Время | Достаточно для | +|-----------|------------|-----------------------| +| 30 | ~1-2 мин | bookings, но не bp | +| 60 | ~5 мин | предположительно bp | +| 90 | ~10+ мин | всех таблиц (?) | + +Для студента ждать 10 мин при первом запуске — плохой UX. + +### Альтернативы + +1. **Готовый SQL-дамп** первых N дней (`pg_dump` → `pg_restore`, секунды) +2. **Docker image с предзаполненной БД** (ещё быстрее, 0 ожидания) +3. **Уменьшить масштаб** — найти параметры demodb для меньшего числа аэропортов/маршрутов + +--- + +## Рекомендации (TODO) + +- [x] Обновить demodb до последнего коммита (`866e56f`) — upstream только README-правки, баги не исправлены +- [x] Добавить патч для gen.connstr (`install_connstr_no_hardcode.patch`) — убирает хардкод без credentials +- [x] Увеличить BOOKINGS_INIT_DAYS до 60 (в Makefile и .env) +- [x] Добавить валидацию после init (count > 0 для bookings, flights; вывод counts всех таблиц) +- [ ] Решить проблему UX с временем генерации (дамп или prebuilt image) +- [ ] Проверить, появляются ли boarding_passes при init_days=60 + исправленном connstr (нужен запуск стенда) diff --git a/docs/reference/bookings_generation_benchmark.md b/docs/reference/bookings_generation_benchmark.md new file mode 100644 index 0000000..e5bbd3d --- /dev/null +++ b/docs/reference/bookings_generation_benchmark.md @@ -0,0 +1,75 @@ +# Бенчмарк генерации bookings: параллельность и тюнинг PostgreSQL + +> Дата: 2026-03-09 +> Железо: Intel Core i7-11800H @ 2.30GHz (8 ядер / 16 потоков), игровой ноутбук +> Среда: WSL2, Docker Desktop, PostgreSQL 16 в контейнере +> Данные: seed-дамп 60 дней (~500k bookings, ~180k boarding_passes, ~1.2M tickets) + +## Контекст + +Генератор demodb (`postgrespro/demodb`) использует очередь событий `gen.events` с обработкой через `process_queue()`. При `jobs>1` воркеры запускаются через `dblink_send_query` и конкурируют за очередь через `SELECT ... FOR UPDATE SKIP LOCKED`. + +Задача: найти оптимальную конфигурацию для `make bookings-generate-day` (инкремент +1 день). + +## Методика + +- Между экспериментами: `make bookings-init BOOKINGS_JOBS=N` (восстановление из seed-дампа, ~18 сек). +- Замер: `time make bookings-generate-day BOOKINGS_JOBS=N`. +- Контроль: `max(book_date)` должен сдвинуться на +1 день. +- Тюнинг PG: `ALTER SYSTEM SET` + `docker compose restart bookings-db`. + +Параметры тюнинга PostgreSQL: +``` +shared_buffers = 512MB (дефолт: 128MB) +work_mem = 64MB (дефолт: 4MB) +maintenance_work_mem = 256MB (дефолт: 64MB) +effective_cache_size = 1GB (дефолт: 4GB) +wal_buffers = 16MB (дефолт: -1, авто) +checkpoint_completion_target = 0.9 (дефолт: 0.9) +random_page_cost = 1.1 (дефолт: 4.0) +``` + +## Результаты + +| # | Тюнинг PG | sync_commit | Jobs | Время | vs baseline | +|---|-----------|-------------|------|--------|--------------| +| 1 | нет | on | 1 | 2m 48s | **baseline** | +| 2 | нет | on | 2 | 8m 36s | 3× хуже | +| 3 | да | on | 1 | 3m 01s | шум | +| 4 | да | on | 2 | 8m 42s | 3× хуже | +| 5 | да | off | 1 | 2m 50s | шум | +| 6 | да | off | 2 | 8m 39s | 3× хуже | + +Повторный инкремент (прогретый кэш): 5m 03s (jobs=2), не замерялся (jobs=1). + +## Выводы + +### 1. jobs=1 оптимален для инкрементов + +Для генерации +1 дня параллельность через dblink **контрпродуктивна**: +- Два воркера конкурируют за одну очередь `gen.events` через `FOR UPDATE SKIP LOCKED` — это row-level lock contention. +- Overhead: dblink-соединения, синхронизация через `gen.stat_jobs`, polling через `dblink_is_busy()`. +- На WSL2 дополнительно: виртуализированный I/O не масштабируется при параллельных записях. + +При генерации с нуля (`make bookings-generate`, десятки тысяч событий) jobs>1 **может** давать прирост, но не тестировалось в этом бенчмарке. + +### 2. Тюнинг PostgreSQL не влияет + +Увеличение shared_buffers в 4 раза, work_mem в 16 раз и т.д. не дало измеримого эффекта. Узкое место — не буферы и не I/O, а сама логика генератора: последовательная обработка событий с COMMIT после каждого. + +### 3. synchronous_commit=off не влияет + +Генератор делает COMMIT после каждого события (~сотни раз за день). Ожидалось, что `synchronous_commit=off` ускорит запись WAL. Эффект не обнаружен — вероятно, WAL-буферы и так справляются при одном потоке. + +### 4. VACUUM-ивенты — отдельная проблема + +Генератор вставляет в очередь событие `VACUUM` каждую неделю модельного времени. Обработчик запускает `VACUUM ANALYZE` всей базы через `dblink_exec`. При 500k+ строках это занимает минуты. Решение: `DELETE FROM gen.events WHERE type = 'VACUUM'` перед `continue()` в инкрементальных скриптах. + +## Итоговая конфигурация + +``` +BOOKINGS_JOBS=1 # синхронно, без dblink — ~3 мин на +1 день +BOOKINGS_INIT_DAYS=60 # seed-дамп покрывает ~34 дня бронирований + boarding_passes +``` + +Тюнинг PostgreSQL не требуется для текущих объёмов данных. diff --git a/docs/reference/bookings_tz.md b/docs/reference/bookings_tz.md new file mode 100644 index 0000000..fe0c41a --- /dev/null +++ b/docs/reference/bookings_tz.md @@ -0,0 +1,29 @@ +# Временное ТЗ по блоку bookings (для текущей разработки) + +_Внутренний файл для наставника: поясняет, как устроен источник `bookings-db` и генерация данных. Студентам обычно не нужен._ + +- Контейнер `bookings-db` — отдельный сервис Postgres из `docker-compose.yml`, база по умолчанию `demo` (из upstream demodb), без переименований. +- Доступ снаружи не блокируем (порт `5434` по умолчанию), чтобы позже читать через PXF и подключаться из Greenplum. +- Инициализация: два способа: + - `make bookings-init` (рекомендуется): быстрое восстановление из seed-дампа (~18 сек). + - `make bookings-generate` (для разработчиков): полная генерация с нуля — клонирует demodb с закреплённым коммитом, накладывает патчи (`engine`: `jobs=1` синхронно + `busy()` игнорирует свой pid; `install.sql`: `DROP DATABASE IF EXISTS`, `connstr` без хардкода), ждёт `pg_isready`, ставит `gen.connstr` и GUC `bookings.start_date/init_days/jobs`, затем запускает `/bookings/generate_next_day.sql` через `psql -f`. Значения по умолчанию: стартовая дата 2017-01-01, `init_days=60`, `jobs=2`. +- Генерация следующего дня: `make bookings-generate-day` прогоняет тот же SQL (читает GUC, вызывает `generate/continue`, ждёт `busy()`, закрывает dblink). При `jobs=1` всё синхронно, без dblink. +- Исходники demodb: клонируем по требованию с фиксированным хешем, кладём в `bookings/demodb/` (в `.gitignore`), патчи лежат в `bookings/patches/` и применяются автоматически при `make bookings-generate`. +- Документация: в README описаны команды (`bookings-init`, `bookings-generate`, проверка данных, генерация дня), параметры `.env`; настройка PXF/ETL — следующий этап. + +## Текущее состояние +- `make bookings-init` — быстрое восстановление из seed-дампа (~18 сек), рекомендуется для студентов. +- `make bookings-generate` — полная генерация с нуля: автоматически применяет патчи (`engine_jobs1_sync.patch`, `install_drop_if_exists.patch`), ждёт готовности Postgres через `pg_isready`, запускает `install.sql`, выставляет `gen.connstr`/GUC и вызывает `generate_next_day.sql` через `psql -f`. +- Дефолты: `BOOKINGS_START_DATE=2017-01-01`, `BOOKINGS_INIT_DAYS=60`, `BOOKINGS_JOBS=2`. При `jobs=1` генерация идёт синхронно без dblink, `busy()` не учитывает текущую сессию. +- `.env.example`/README обновлены под новые дефолты; каталог `bookings/demodb/` в `.gitignore`. +- Патчи лежат в `bookings/patches/` и накладываются при `bookings-clone-demodb`. + +## Текущее состояние тестов/проблем +- Чистый прогон `make bookings-init` (восстановление из seed-дампа) проходит за ~18 секунд. +- Чистый прогон `make bookings-generate` (после `docker compose down -v` и удаления `bookings/demodb`) проходит за ~1,5 минуты: база ставится, `busy()` → `f`, `bookings.bookings` от `2017-01-01 00:00:18` до `2017-01-01 23:59:59`. +- Ранее зависание на `busy()` при `jobs=1` лечится патчем: `process_queue` теперь синхронный, а `busy()` игнорирует текущий backend. +- Данных пока только на 1 день по умолчанию, чтобы генерация не занимала много времени. + +## Идеи/следующие шаги +- Если понадобится больше дней — увеличивать `BOOKINGS_INIT_DAYS`, но помнить, что генерация может идти долго; контролировать через `SELECT busy();`. +- Следующий этап — PXF/ETL в Greenplum; текущая задача — лишь подготовить источник bookings. diff --git a/docs/internal/pxf_bookings.md b/docs/reference/pxf_bookings.md similarity index 99% rename from docs/internal/pxf_bookings.md rename to docs/reference/pxf_bookings.md index b26f510..702b637 100644 --- a/docs/internal/pxf_bookings.md +++ b/docs/reference/pxf_bookings.md @@ -101,7 +101,7 @@ - причина: файлы уже лежат в `PXF_BASE` на томе, а seed из образа по умолчанию не перетирает их; - решение: `make build` + restart `greenplum` + (при необходимости) `PXF_SEED_OVERWRITE=1`. -## 9. Известная проблема: `protocol "pxf" does not exist` на «холодном старте» (исправлено) +## 8. Известная проблема: `protocol "pxf" does not exist` на «холодном старте» (исправлено) Раньше (воспроизводилось в `./scripts/e2e_smoke.sh`) при первом `make ddl-gp` можно было получить: @@ -137,7 +137,7 @@ 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'';\"'"` -## 8. Связанные файлы +## 9. Связанные файлы - `Dockerfile.greenplum` - `docker-compose.yml` (сервис `greenplum`: `build`, `hostname`, env, healthcheck) diff --git a/docs/reference/qa-plan.md b/docs/reference/qa-plan.md new file mode 100644 index 0000000..d20678d --- /dev/null +++ b/docs/reference/qa-plan.md @@ -0,0 +1,595 @@ +# План отладки эталонного пайплайна + +> Цель: убедиться, что эталонный вертикальный срез (STG→ODS→DDS→DM) работает +> корректно при разных сценариях. Найти и исправить баги ДО того, как начнём +> готовить ветку main для студентов. +> +> Аудитория документа: AI-агент (Sonnet) или человек, выполняющий отладку. +> +> Зависимость: перед запуском этого плана нужно починить bookings-db +> (см. `docs/reference/bookings_db_issues.md`). + +--- + +## Предусловия + +```bash +make up # поднять стенд +make bookings-init # инициализировать bookings-db (демо-данные) +``` + +Все проверки выполняются через `make gp-psql` (psql к Greenplum) и Airflow REST API. +Для запуска DAG из CLI: + +```bash +# Запуск DAG и получение run_id +curl -s -u admin:admin -X POST \ + "http://localhost:8080/api/v1/dags//dagRuns" \ + -H "Content-Type: application/json" \ + -d '{"conf":{}}' | jq .dag_run_id + +# Проверка статуса +curl -s -u admin:admin \ + "http://localhost:8080/api/v1/dags//dagRuns?order_by=-start_date&limit=1" \ + | jq '.dag_runs[0].state' +``` + +Перед началом тестов — убедиться, что `make test` проходит локально. + +--- + +## Блок 1: Чистый прогон (Day 1) + +**Цель:** убедиться, что пайплайн работает на свежих данных без ошибок. + +### 1.1 Сброс и загрузка + +```bash +make dwh-truncate # очистить все таблицы GP +``` + +Запустить DAG'и в порядке: +1. `bookings_stg_ddl` → дождаться success +2. `bookings_ods_ddl` → дождаться success +3. `bookings_dds_ddl` → дождаться success +4. `bookings_dm_ddl` → дождаться success +5. `bookings_to_gp_stage` → дождаться success +6. `bookings_to_gp_ods` → дождаться success +7. `bookings_to_gp_dds` → дождаться success +8. `bookings_to_gp_dm` → дождаться success + +### 1.2 Проверки после Day 1 + +Все запросы выполнять в `make gp-psql`. + +#### A. Непустота всех таблиц + +```sql +-- STG: все 9 таблиц не пустые +SELECT 'stg.bookings' AS tbl, COUNT(*) FROM stg.bookings +UNION ALL SELECT 'stg.tickets', COUNT(*) FROM stg.tickets +UNION ALL SELECT 'stg.segments', COUNT(*) FROM stg.segments +UNION ALL SELECT 'stg.flights', COUNT(*) FROM stg.flights +UNION ALL SELECT 'stg.airports', COUNT(*) FROM stg.airports +UNION ALL SELECT 'stg.airplanes', COUNT(*) FROM stg.airplanes +UNION ALL SELECT 'stg.routes', COUNT(*) FROM stg.routes +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 таблиц не пустые +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 +UNION ALL SELECT 'ods.flights', COUNT(*) FROM ods.flights +UNION ALL SELECT 'ods.airports', COUNT(*) FROM ods.airports +UNION ALL SELECT 'ods.airplanes', COUNT(*) FROM ods.airplanes +UNION ALL SELECT 'ods.routes', COUNT(*) FROM ods.routes +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 таблиц не пустые +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 +UNION ALL SELECT 'dds.dim_tariffs', COUNT(*) FROM dds.dim_tariffs +UNION ALL SELECT 'dds.dim_passengers', COUNT(*) FROM dds.dim_passengers +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 витрин не пустые +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 +UNION ALL SELECT 'dm.airport_traffic', COUNT(*) FROM dm.airport_traffic +UNION ALL SELECT 'dm.monthly_overview', COUNT(*) FROM dm.monthly_overview +ORDER BY 1; +``` + +**Ожидание:** ВСЕ таблицы > 0 строк. Если какая-то пустая — это баг. + +#### B. Сквозная сверка количеств (STG → ODS) + +```sql +-- Инкрементальные таблицы: на Day 1 должно быть ODS = STG +SELECT + 'bookings' AS entity, + (SELECT COUNT(*) FROM stg.bookings) AS stg_cnt, + (SELECT COUNT(*) FROM ods.bookings) AS ods_cnt +UNION ALL SELECT 'tickets', + (SELECT COUNT(*) FROM stg.tickets), + (SELECT COUNT(*) FROM ods.tickets) +UNION ALL SELECT 'segments', + (SELECT COUNT(*) FROM stg.segments), + (SELECT COUNT(*) FROM ods.segments) +UNION ALL SELECT 'flights', + (SELECT COUNT(*) FROM stg.flights), + (SELECT COUNT(*) FROM ods.flights) +UNION ALL SELECT 'boarding_passes', + (SELECT COUNT(*) FROM stg.boarding_passes), + (SELECT COUNT(*) FROM ods.boarding_passes); +``` + +**Ожидание Day 1:** `stg_cnt = ods_cnt` для инкрементальных таблиц. + +```sql +-- Снапшот-таблицы: ODS = последний батч STG +SELECT + 'airports' AS entity, + (SELECT COUNT(*) FROM stg.airports + WHERE _load_id = (SELECT MAX(_load_id) FROM stg.airports)) AS stg_cnt, + (SELECT COUNT(*) FROM ods.airports) AS ods_cnt +UNION ALL SELECT 'airplanes', + (SELECT COUNT(*) FROM stg.airplanes + WHERE _load_id = (SELECT MAX(_load_id) FROM stg.airplanes)), + (SELECT COUNT(*) FROM ods.airplanes) +UNION ALL SELECT 'routes', + (SELECT COUNT(*) FROM stg.routes + WHERE _load_id = (SELECT MAX(_load_id) FROM stg.routes)), + (SELECT COUNT(*) FROM ods.routes) +UNION ALL SELECT 'seats', + (SELECT COUNT(*) FROM stg.seats + WHERE _load_id = (SELECT MAX(_load_id) FROM stg.seats)), + (SELECT COUNT(*) FROM ods.seats); +``` + +**Ожидание:** `stg_cnt = ods_cnt` для снапшот-таблиц. + +#### C. Сквозная сверка количеств (ODS → DDS) + +```sql +-- Измерения SCD1: число уникальных бизнес-ключей +SELECT + 'airports' AS entity, + (SELECT COUNT(DISTINCT airport_code) FROM ods.airports) AS ods_bk, + (SELECT COUNT(*) FROM dds.dim_airports) AS dds_cnt +UNION ALL SELECT 'airplanes', + (SELECT COUNT(DISTINCT airplane_code) FROM ods.airplanes), + (SELECT COUNT(*) FROM dds.dim_airplanes) +UNION ALL SELECT 'tariffs', + (SELECT COUNT(DISTINCT fare_conditions) FROM ods.segments), + (SELECT COUNT(*) FROM dds.dim_tariffs) +UNION ALL SELECT 'passengers', + (SELECT COUNT(DISTINCT passenger_id) FROM ods.tickets), + (SELECT COUNT(*) FROM dds.dim_passengers); +``` + +**Ожидание:** `ods_bk = dds_cnt` (на первый прогон, без SCD-истории). + +```sql +-- Маршруты SCD2: текущих записей = уникальных бизнес-ключей в ODS +-- Примечание: бизнес-ключ = route_no (не route_no || '-' || validity). +-- Один route_no может иметь несколько периодов validity в ods.routes, +-- но DDS берёт только самый свежий (rn=1 по validity DESC). +SELECT + (SELECT COUNT(DISTINCT route_no) FROM ods.routes) AS ods_routes, + (SELECT COUNT(*) FROM dds.dim_routes WHERE valid_to IS NULL) AS dds_current, + (SELECT COUNT(*) FROM dds.dim_routes) AS dds_total; +``` + +**Ожидание Day 1:** `ods_routes = dds_current = dds_total` (без истории). + +```sql +-- Факт: grain = segments +SELECT + (SELECT COUNT(*) FROM ods.segments) AS ods_segments, + (SELECT COUNT(*) FROM dds.fact_flight_sales) AS dds_fact; +``` + +**Ожидание:** `ods_segments = dds_fact`. + +#### D. Сквозная сверка: DDS → DM (бизнес-метрики) + +```sql +-- Общая выручка: fact vs sales_report +-- Примечание: колонка называется price (не ticket_price) +SELECT + (SELECT SUM(price) FROM dds.fact_flight_sales) AS fact_revenue, + (SELECT SUM(total_revenue) FROM dm.sales_report) AS dm_revenue; +``` + +**Ожидание:** `fact_revenue = dm_revenue` (или объяснимая разница из-за логики витрины). + +```sql +-- Количество уникальных пассажиров: fact vs passenger_loyalty +SELECT + (SELECT COUNT(DISTINCT passenger_sk) FROM dds.fact_flight_sales + WHERE passenger_sk IS NOT NULL) AS fact_passengers, + (SELECT COUNT(*) FROM dm.passenger_loyalty) AS dm_passengers; +``` + +**Ожидание:** совпадение (или объяснимая разница). + +```sql +-- Общее число посадок: fact vs sales_report +-- Примечание: колонка называется is_boarded (не boarding_seq) +SELECT + (SELECT COUNT(*) FROM dds.fact_flight_sales + WHERE is_boarded = TRUE) AS fact_boarded, + (SELECT SUM(passengers_boarded) FROM dm.sales_report) AS dm_boarded; +``` + +#### E. Целостность суррогатных ключей (FK в DDS и DM) + +```sql +SELECT 'orphan_route_sk' AS check_name, COUNT(*) AS orphans +FROM dds.fact_flight_sales f +LEFT JOIN dds.dim_routes r ON f.route_sk = r.route_sk +WHERE f.route_sk IS NOT NULL AND r.route_sk IS NULL + +UNION ALL +SELECT 'orphan_airport_sk', COUNT(*) +FROM dm.sales_report sr +LEFT JOIN dds.dim_airports a ON sr.airport_sk = a.airport_sk +WHERE sr.airport_sk IS NOT NULL AND a.airport_sk IS NULL + +UNION ALL +SELECT 'orphan_tariff_sk', COUNT(*) +FROM dds.fact_flight_sales f +LEFT JOIN dds.dim_tariffs t ON f.tariff_sk = t.tariff_sk +WHERE f.tariff_sk IS NOT NULL AND t.tariff_sk IS NULL + +UNION ALL +SELECT 'orphan_passenger_sk', COUNT(*) +FROM dds.fact_flight_sales f +LEFT JOIN dds.dim_passengers p ON f.passenger_sk = p.passenger_sk +WHERE f.passenger_sk IS NOT NULL AND p.passenger_sk IS NULL + +UNION ALL +SELECT 'orphan_calendar_sk', COUNT(*) +FROM dds.fact_flight_sales f +LEFT JOIN dds.dim_calendar c ON f.calendar_sk = c.calendar_sk +WHERE f.calendar_sk IS NOT NULL AND c.calendar_sk IS NULL; +``` + +**Ожидание:** ВСЕ orphans = 0. + +--- + +## Блок 2: Идемпотентность (повторный Day 1) + +**Цель:** повторный прогон ODS/DDS/DM НЕ дублирует данные при неизменённом STG. + +**Важно:** `bookings_to_gp_stage` ВСЕГДА генерирует следующий день при запуске +(через `generate_bookings_day`). Тест идемпотентности нужно проводить только для +`bookings_to_gp_ods`, `bookings_to_gp_dds`, `bookings_to_gp_dm` — без повторного +запуска STG. Убедитесь, что все STG-батчи уже обработаны ODS перед тестом: + +```sql +SELECT COUNT(*) AS unprocessed +FROM stg.bookings +WHERE _load_ts > (SELECT COALESCE(MAX(_load_ts), '1900-01-01') FROM ods.bookings); +-- Ожидание: 0 +``` + +### 2.1 Зафиксировать counts после Day 1 + +```sql +SELECT 'ods.bookings' AS tbl, COUNT(*) AS cnt FROM ods.bookings +UNION ALL SELECT 'ods.tickets', COUNT(*) FROM ods.tickets +UNION ALL SELECT 'ods.segments', COUNT(*) FROM ods.segments +UNION ALL SELECT 'ods.flights', COUNT(*) FROM ods.flights +UNION ALL SELECT 'dds.fact_flight_sales', COUNT(*) FROM dds.fact_flight_sales +UNION ALL SELECT 'dds.dim_routes', COUNT(*) FROM dds.dim_routes +UNION ALL SELECT 'dm.sales_report', COUNT(*) FROM dm.sales_report +UNION ALL SELECT 'dm.passenger_loyalty', COUNT(*) FROM dm.passenger_loyalty +ORDER BY 1; +``` + +Записать результаты. + +### 2.2 Повторный прогон (без генерации нового дня!) + +Запустить снова: STG → ODS → DDS → DM (4 load-DAG'а). + +### 2.3 Проверить, что counts не изменились + +Повторить запрос из 2.1 и сравнить. + +**Ожидание:** +- Инкрементальные (bookings, tickets, segments, flights, boarding_passes, + fact_flight_sales) — count НЕ увеличился +- Снапшоты (airports, airplanes, routes, seats) — count тот же +- DM (full rebuild) — count тот же + +**Если count вырос — баг идемпотентности.** Записать, в какой таблице и на сколько. + +--- + +## Блок 3: Инкремент (Day 2) + +**Цель:** после генерации нового дня инкрементальные таблицы растут, +снапшоты обновляются, витрины обогащаются. + +### 3.1 Генерация нового дня + +```bash +make bookings-generate-day +``` + +### 3.2 Запуск пайплайна + +Запустить STG → ODS → DDS → DM (4 load-DAG'а). + +### 3.3 Проверки после Day 2 + +#### A. Инкрементальные таблицы выросли + +```sql +SELECT 'stg.bookings' AS tbl, COUNT(*) FROM stg.bookings +UNION ALL SELECT 'ods.bookings', COUNT(*) FROM ods.bookings +UNION ALL SELECT 'ods.tickets', COUNT(*) FROM ods.tickets +UNION ALL SELECT 'ods.segments', COUNT(*) FROM ods.segments +UNION ALL SELECT 'dds.fact_flight_sales', COUNT(*) FROM dds.fact_flight_sales +ORDER BY 1; +``` + +**Ожидание:** все count'ы > Day 1. + +#### B. STG хранит оба батча + +```sql +SELECT _load_id, COUNT(*) FROM stg.bookings GROUP BY 1 ORDER BY 1; +``` + +**Ожидание:** 2 разных `_load_id`, оба с данными. + +#### C. Снапшоты не дублировались + +```sql +SELECT + 'airports' AS entity, + (SELECT COUNT(*) FROM stg.airports + WHERE _load_id = (SELECT MAX(_load_id) FROM stg.airports)) AS stg_last_batch, + (SELECT COUNT(*) FROM ods.airports) AS ods_cnt +UNION ALL SELECT 'airplanes', + (SELECT COUNT(*) FROM stg.airplanes + WHERE _load_id = (SELECT MAX(_load_id) FROM stg.airplanes)), + (SELECT COUNT(*) FROM ods.airplanes) +UNION ALL SELECT 'routes', + (SELECT COUNT(*) FROM stg.routes + WHERE _load_id = (SELECT MAX(_load_id) FROM stg.routes)), + (SELECT COUNT(*) FROM ods.routes) +UNION ALL SELECT 'seats', + (SELECT COUNT(*) FROM stg.seats + WHERE _load_id = (SELECT MAX(_load_id) FROM stg.seats)), + (SELECT COUNT(*) FROM ods.seats); +``` + +**Ожидание:** `stg_last_batch = ods_cnt`. + +#### D. SCD2 dim_routes — версионность + +```sql +SELECT + COUNT(*) AS total_rows, + COUNT(*) FILTER (WHERE valid_to IS NULL) AS current_rows, + COUNT(*) FILTER (WHERE valid_to IS NOT NULL) AS closed_rows +FROM dds.dim_routes; +``` + +**Ожидание:** `closed_rows = 0` если справочник маршрутов не менялся. +Если `closed_rows > 0` — проверить, действительно ли атрибуты изменились: + +```sql +SELECT route_bk, valid_from, valid_to, hashdiff +FROM dds.dim_routes +WHERE route_bk IN ( + SELECT route_bk FROM dds.dim_routes GROUP BY route_bk HAVING COUNT(*) > 1 +) +ORDER BY route_bk, valid_from; +``` + +#### E. DM после инкремента + +```sql +SELECT MIN(flight_date), MAX(flight_date), COUNT(DISTINCT flight_date) +FROM dm.sales_report; +``` + +**Ожидание:** диапазон дат шире, чем после Day 1. + +--- + +## Блок 4: Многодневный прогон (Days 3-5) + +**Цель:** поймать баги, которые проявляются только при накоплении данных. + +### 4.1 Цикл + +Повторить 3 раза: +```bash +make bookings-generate-day +# Запустить STG → ODS → DDS → DM +``` + +### 4.2 Проверки после 5 дней + +#### A. Монотонный рост инкрементальных таблиц + +```sql +SELECT _load_id, COUNT(*) FROM stg.bookings GROUP BY 1 ORDER BY 1; +``` + +**Ожидание:** 5 строк, все с данными. + +#### B. Нет дупликатов в ODS (критически важно!) + +```sql +SELECT 'ods.bookings' AS tbl, + COUNT(*) - COUNT(DISTINCT book_ref) AS dups FROM ods.bookings +UNION ALL SELECT 'ods.tickets', + COUNT(*) - COUNT(DISTINCT ticket_no) FROM ods.tickets +UNION ALL SELECT 'ods.flights', + COUNT(*) - COUNT(DISTINCT flight_id) FROM ods.flights +UNION ALL SELECT 'ods.segments', + COUNT(*) - COUNT(DISTINCT ticket_no || '-' || flight_id::text) FROM ods.segments +UNION ALL SELECT 'ods.boarding_passes', + COUNT(*) - COUNT(DISTINCT ticket_no || '-' || flight_id::text) FROM ods.boarding_passes; +``` + +**Ожидание:** ВСЕ dups = 0. Если > 0 — **критический баг**. + +#### C. Нет дупов в DDS fact + +```sql +SELECT COUNT(*) - COUNT(DISTINCT ticket_no || '-' || flight_id::text) AS dups +FROM dds.fact_flight_sales; +``` + +**Ожидание:** 0. + +#### D. Рост витрин осмысленный + +```sql +SELECT + (SELECT COUNT(*) FROM dm.sales_report) AS sales_rows, + (SELECT COUNT(*) FROM dm.route_performance) AS route_rows, + (SELECT COUNT(*) FROM dm.passenger_loyalty) AS passenger_rows, + (SELECT COUNT(*) FROM dm.airport_traffic) AS traffic_rows, + (SELECT COUNT(*) FROM dm.monthly_overview) AS monthly_rows; +``` + +Сравнить с Day 1. Ожидание: `sales_rows` и `traffic_rows` растут (по дням), +`route_rows` стабильны (по маршрутам), `passenger_rows` растут или стабильны. + +--- + +## Блок 5: Валидация SQL-скриптов (статический анализ) + +**Цель:** проверить качество кода без запуска стенда. Можно делать параллельно +с блоками 1-4. + +### 5.1 Консистентность _load_id во всех слоях + +```bash +# В STG/ODS/DDS/DM должен быть _load_id +grep -r '_load_id' sql/stg/*_load.sql sql/ods/*_load.sql sql/dds/*_load.sql sql/dm/*_load.sql | head -20 + +# Не должно быть старых имён batch_id, load_dttm, src_created_at_ts +grep -r '\bbatch_id\b' sql/stg/ sql/ods/ sql/dds/ sql/dm/ # ожидание: только допустимые переменные PL (v_batch_id) +grep -r 'load_dttm\|src_created_at_ts' sql/ # ожидание: пусто +``` + +### 5.2 Все load.sql используют шаблон {{ run_id }} + +Примечание: ODS намеренно не использует `{{ run_id }}` — вместо этого в `_load_id` +сохраняется `_load_id` из STG для сквозного lineage (traceable to source batch). +Это правильный паттерн, а не баг. Проверять нужно только STG/DDS/DM. + +```bash +for f in sql/stg/*_load.sql sql/dds/*_load.sql sql/dm/*_load.sql; do + if ! grep -q '{{ run_id }}\|{{ ti.xcom_pull' "$f"; then + echo "WARN: $f не содержит {{ run_id }}" + fi +done +``` + +### 5.3 DDL и load совпадают по набору колонок + +Для каждой пары `_ddl.sql` / `_load.sql`: +- Извлечь список колонок из DDL (CREATE TABLE) +- Извлечь список колонок из INSERT в load.sql +- Сравнить + +**Ожидание:** списки совпадают (за исключением SERIAL/GENERATED колонок). + +Приоритетные пары для ручной проверки (сложные): +- `dds/fact_flight_sales` (много FK) +- `dds/dim_routes` (SCD2-поля) +- `dm/sales_report` (агрегаты) + +### 5.4 DQ-скрипты согласованы с DDL + +Для каждого `_dq.sql` проверить: +- Все NOT NULL колонки из DDL проверяются в DQ? +- Все бизнес-ключи из DDL проверяются на дупликаты? +- FK-проверки ссылаются на правильные таблицы? + +--- + +## Блок 6: Граничные случаи + +### 6.1 Пустой инкремент + +Запустить STG DAG БЕЗ предварительной генерации нового дня. + +**Ожидание:** DAG завершается success (не failure). Таблицы не меняются. +DQ-скрипты не падают на пустом батче. + +### 6.2 DDL DAG на уже существующих таблицах + +Запустить `bookings_stg_ddl` повторно (таблицы уже есть). + +**Ожидание:** success. DDL использует `IF NOT EXISTS`. Данные не потеряны. + +### 6.3 Служебные поля заполнены + +```sql +SELECT 'ods.bookings' AS tbl, + COUNT(*) FILTER (WHERE _load_id IS NULL) AS null_load_id, + COUNT(*) FILTER (WHERE _load_ts IS NULL) AS null_load_ts +FROM ods.bookings +UNION ALL SELECT 'dds.fact_flight_sales', + COUNT(*) FILTER (WHERE _load_id IS NULL), + COUNT(*) FILTER (WHERE _load_ts IS NULL) +FROM dds.fact_flight_sales +UNION ALL SELECT 'dds.dim_routes', + COUNT(*) FILTER (WHERE _load_id IS NULL), + COUNT(*) FILTER (WHERE _load_ts IS NULL) +FROM dds.dim_routes; +``` + +**Ожидание:** все null_* = 0. + +--- + +## Формат отчёта + +По каждому блоку фиксировать: + +| Блок | Проверка | Статус | Детали | +|------|----------|--------|--------| +| 1.2A | Непустота таблиц | OK / FAIL | какая таблица пуста | +| 1.2B | STG→ODS counts | OK / FAIL | расхождение: X vs Y | +| ... | ... | ... | ... | + +Если найден баг: +1. Описать симптом (какой запрос, какой результат) +2. Локализовать (в каком SQL-файле проблема) +3. Предложить fix +4. После fix — повторить проверку + +--- + +## Приоритеты (если время ограничено) + +1. **Блок 1** (чистый прогон) — обязательно, базовый smoke +2. **Блок 2** (идемпотентность) — обязательно, частый источник багов +3. **Блок 3** (инкремент Day 2) — обязательно, проверяет главную фичу +4. **Блок 5.3** (DDL ↔ load) — высокий приоритет, ловит рассинхрон колонок +5. **Блок 4** (5 дней) — средний приоритет, ловит накопительные баги +6. **Блок 6** (граничные) — средний приоритет +7. **Блок 5.1-5.4** (статика) — можно делать параллельно без стенда diff --git a/docs/stack.md b/docs/stack.md index 4a706c6..0f5a51b 100644 --- a/docs/stack.md +++ b/docs/stack.md @@ -54,7 +54,7 @@ make gp-psql PXF (Platform Extension Framework) — компонент Greenplum для работы с внешними источниками. В этом стенде PXF используется для чтения таблицы `bookings.bookings` из Postgres прямо из Greenplum через внешнюю таблицу `stg.bookings_ext`. Поэтому загрузка в `stg.bookings` выглядит как обычный -`INSERT ... SELECT` без промежуточных CSV. +`INSERT ... SELECT` без промежуточных файлов. ## Greenplum + PXF: свой образ @@ -82,7 +82,7 @@ docker compose ps # проверить health - `PXF_SEED_OVERWRITE=1` — перезаписать конфиги при старте; - `PXF_SYNC_ON_START=1` — выполнить `pxf cluster sync` при старте. -Подробнее: `docs/internal/pxf_bookings.md`. +Подробнее: `docs/reference/pxf_bookings.md`. ## Airflow: свой образ @@ -131,10 +131,5 @@ make fmt - `BOOKINGS_DB_USER`, `BOOKINGS_DB_PASSWORD` - `BOOKINGS_DB_PORT` — внешний порт (по умолчанию `5434`) - `BOOKINGS_START_DATE` — стартовая дата модельного времени -- `BOOKINGS_INIT_DAYS` — сколько дней генерировать при первом `make bookings-init` -- `BOOKINGS_JOBS` — число джобов генератора; в учебном стенде поддерживается только `1` - -### CSV pipeline (побочный пример) - -- `CSV_DIR` — путь к каталогу с CSV внутри контейнеров Airflow (по умолчанию `/opt/airflow/data`) -- `CSV_ROWS` — количество строк, генерируемых DAG (по умолчанию `1000`) +- `BOOKINGS_INIT_DAYS` — сколько дней генерировать при `make bookings-generate` (генерация с нуля) +- `BOOKINGS_JOBS` — число джобов генератора (по умолчанию `1`) diff --git a/plans/bookings-demodb-bugfix-plan.md b/plans/bookings-demodb-bugfix-plan.md deleted file mode 100644 index 5cd35b4..0000000 --- a/plans/bookings-demodb-bugfix-plan.md +++ /dev/null @@ -1,98 +0,0 @@ -# План исправления: генератор demodb (bookings) остаётся пустым - -## Контекст - -В стенде используется демобаза bookings из репозитория `postgrespro/demodb`, закреплённая на коммите `d68de192850237719f09b47688d5f3fc94653ca6` (см. `DEMODB_COMMIT` в `Makefile`). - -Инициализация источника для ETL выполняется командой `make bookings-init`: -- клонирует demodb в `bookings/demodb/`; -- пытается применить патчи из `bookings/patches/`; -- запускает `install.sql` в контейнере `bookings-db`; -- выставляет GUC-параметры (`gen.connstr`, `bookings.start_date/init_days/jobs`); -- запускает `/bookings/generate_next_day.sql` (должен сгенерировать минимум 1 день данных). - -## Симптомы (как в TODO) - -- после `make bookings-init` таблица `bookings.bookings` остаётся пустой; -- патчи `bookings/patches/engine_jobs1_sync.patch` и `bookings/patches/install_drop_if_exists.patch` падают при применении; -- из‑за этого DAG `bookings_to_gp_stage` валится на проверках (источник пустой). - -## Предварительный диагноз (что уже видно) - -1) `engine_jobs1_sync.patch` не является валидным unified diff (в hunk’ах нет номеров строк вида `@@ -N,M +N,M @@`), поэтому `patch` отвечает: -`patch: **** Only garbage was found in the patch input.` - -2) `install_drop_if_exists.patch` устарел относительно закреплённого коммита demodb: в `install.sql` уже есть `DROP DATABASE IF EXISTS demo;`, поэтому hunk “не находится” и патч не накатывается. - -3) Ошибки патча сейчас замаскированы в `Makefile` через `|| true`, поэтому `make bookings-init` может завершаться “успешно”, хотя критичные правки в demodb не применились. - -## Цель фикса - -- `make bookings-init` воспроизводимо создаёт и наполняет `demo.bookings.bookings` (>0 строк). -- Если патчи не применяются — процесс останавливается с понятным сообщением, что делать дальше. -- Патчи соответствуют закреплённому коммиту demodb и применяются идемпотентно. - -## План диагностики (чтобы быстро подтвердить проблему) - -1) Чистое воспроизведение: -- `make clean` -- `rm -rf bookings/demodb` -- `make bookings-init` - -2) Проверка данных: -- `make bookings-psql` -- выполнить: - - `SELECT COUNT(*) FROM bookings.bookings;` - - `SELECT min(book_date), max(book_date) FROM bookings.bookings;` - -3) Проверка патчей (без изменения файлов): -- `patch -d bookings/demodb -p1 --dry-run < bookings/patches/engine_jobs1_sync.patch` -- `patch -d bookings/demodb -p1 --dry-run < bookings/patches/install_drop_if_exists.patch` - -Ожидаемо: сейчас dry-run показывает “garbage in patch” и/или “Hunk FAILED”. - -## План решения - -### Шаг 1. Пересобрать патчи под закреплённый демо‑коммит - -Собираем патчи через `git diff`, чтобы получился корректный unified diff. - -1) `bookings/patches/engine_jobs1_sync.patch`: -- Добавить/подтвердить 2 изменения в `engine.sql`: - - `busy()` игнорирует текущий backend: `AND pid <> pg_backend_pid()`. - - `continue()` при `jobs = 1` выполняет `process_queue(end_date)` синхронно и пишет заметный маркер в лог (`Job 1 (local): ok`), иначе — оставляет текущую логику через `dblink`. - -2) `bookings/patches/install_drop_if_exists.patch`: -- Поменять строку (в актуальном `install.sql`): - - было: `DROP DATABASE IF EXISTS demo;` - - стало: `DROP DATABASE IF EXISTS demo WITH (FORCE);` - -### Шаг 2. Сделать `make bookings-init` fail-fast на проблемах с патчами - -В `Makefile`: -- убрать `|| true` у применения патчей; -- при ошибке патча — завершать `make` с ненулевым кодом и короткой подсказкой: - - “удалите `bookings/demodb` и повторите `make bookings-init`”, - - “если не помогло — проверьте, что `DEMODB_COMMIT` не менялся и патчи собраны под него”. - -### Шаг 3. Добавить “защиту от тихого пустого результата” - -После запуска `/bookings/generate_next_day.sql` (в `Makefile` или внутри SQL): -- выполнить проверку `COUNT(*)` по `bookings.bookings`; -- если 0 — завершаться ошибкой с подсказкой, куда смотреть (патчи/логи генератора). - -Цель: чтобы проблема не уезжала дальше в DAG’и и DQ‑проверки, а ловилась сразу при init. - -## Проверка (критерии готовности) - -- `make clean && rm -rf bookings/demodb && make bookings-init` завершается без ошибок. -- `make bookings-psql` → `SELECT COUNT(*) FROM bookings.bookings;` возвращает `> 0`. -- `make bookings-generate-day` добавляет следующий день: - - `max(book_date)` сдвигается на +1 сутки. -- `./scripts/e2e_smoke.sh` проходит до проверки `stg.bookings` (или хотя бы DAG `bookings_to_gp_stage` перестаёт падать на “источник пустой”). - -## Откат (если нужно быстро вернуть стенд в рабочее состояние) - -- Временно отключить применение патчей в `Makefile` и явно предупреждать, что генерация может быть нестабильной (нежелательно для студентов). -- Или зафиксировать альтернативный `DEMODB_COMMIT`, под который уже готовы патчи (делать только вместе с обновлением документации и проверкой, что генерация стабильна). - diff --git a/plans/dockerfile-improvements.md b/plans/dockerfile-improvements.md deleted file mode 100644 index f117053..0000000 --- a/plans/dockerfile-improvements.md +++ /dev/null @@ -1,489 +0,0 @@ -# План улучшения Dockerfile и docker-compose.yml - -## Обзор - -Документ описывает план улучшения Dockerfile для Airflow и его интеграции с docker-compose.yml на основе анализа best practices. - -## Согласованные изменения - -✅ Переименовать `Dockerfile` → `Dockerfile.airflow` -✅ Обновить `build: .` → `build: { context: ., dockerfile: Dockerfile.airflow }` -✅ Добавить YAML anchors для устранения дублирования конфигурации -✅ Добавить healthcheck для airflow-webserver -✅ Исправить расположение requirements.txt в Dockerfile -✅ Добавить LABEL в Dockerfile -✅ Добавить `USER airflow` после установки зависимостей -✅ Добавить проверку `pip check` -✅ Переименовать контейнеры (`gp_airflow_web` → `gp_airflow_webserver`, `gp_airflow_sch` → `gp_airflow_scheduler`) - ---- - -## Часть 1: Изменения в Dockerfile - -### Текущее состояние (Dockerfile) -```dockerfile -FROM apache/airflow:2.9.2 - -COPY airflow/requirements.txt /requirements.txt -RUN pip install --no-cache-dir -r /requirements.txt -``` - -### Новое состояние (Dockerfile.airflow) -```dockerfile -# Apache Airflow с дополнительными зависимостями для Greenplum -FROM apache/airflow:2.9.2 - -LABEL maintainer="your-email@example.com" -LABEL description="Airflow with Greenplum and Pandas dependencies" -LABEL version="1.0" - -# Копируем requirements в стандартное расположение -COPY airflow/requirements.txt /opt/airflow/requirements.txt - -# Устанавливаем зависимости -RUN pip install --no-cache-dir -r /opt/airflow/requirements.txt \ - && pip check \ - && rm -rf /tmp/pip-* - -# Переключаемся на пользователя airflow -USER airflow - -WORKDIR /opt/airflow -``` - -### Обоснование изменений: - -1. **LABEL** - стандартная практика для документирования образов -2. **`/opt/airflow/requirements.txt`** - стандартное расположение для Airflow -3. **`pip check`** - проверка совместимости установленных пакетов -4. **`USER airflow`** - безопасность и соответствие best practices -5. **`WORKDIR /opt/airflow`** - явное указание рабочей директории - ---- - -## Часть 2: Изменения в docker-compose.yml - -### Основные изменения: - -1. **Добавить YAML anchors** для устранения дублирования -2. **Обновить build** для всех Airflow сервисов -3. **Добавить healthcheck** для airflow-webserver -4. **Добавить комментарии** для пояснения сокращений в именах контейнеров - -### Структура YAML anchors: - -```yaml -x-airflow-common-env: &airflow-env - TZ: ${TZ:-Europe/Moscow} - AIRFLOW__CORE__LOAD_EXAMPLES: "False" - AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB} - AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW__WEBSERVER__SECRET_KEY} - AIRFLOW_CONN_GREENPLUM_CONN: postgresql://${GP_USER}:${GP_PASSWORD}@greenplum:${GP_PORT:-5432}/${GP_DB} - AIRFLOW_CONN_BOOKINGS_DB: postgresql://${BOOKINGS_DB_USER}:${BOOKINGS_DB_PASSWORD}@bookings-db:5432/demo - -x-airflow-common-volumes: &airflow-volumes - - ./airflow/dags:/opt/airflow/dags - - ./sql:/sql:ro - - airflow_data:/opt/airflow/data - -x-airflow-common-depends: &airflow-depends - pgmeta: - condition: service_healthy - greenplum: - condition: service_healthy - airflow-init: - condition: service_completed_successfully -``` - -### Изменения для airflow-webserver: - -```yaml -airflow-webserver: - build: - context: . - dockerfile: Dockerfile.airflow - image: airflow-custom:latest - container_name: gp_airflow_webserver - env_file: .env - environment: - <<: *airflow-env - command: airflow webserver - ports: - - "8080:8080" - volumes: - <<: *airflow-volumes - # requirements.txt монтируется как volume для удобства разработки - # При изменении зависимостей не требуется пересборка образа - - ./airflow/requirements.txt:/opt/airflow/requirements.txt - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 40s - depends_on: - <<: *airflow-depends -``` - -### Изменения для airflow-scheduler: - -```yaml -airflow-scheduler: - build: - context: . - dockerfile: Dockerfile.airflow - image: airflow-custom:latest - container_name: gp_airflow_scheduler - env_file: .env - environment: - <<: *airflow-env - command: airflow scheduler - volumes: - <<: *airflow-volumes - - ./airflow/requirements.txt:/opt/airflow/requirements.txt - depends_on: - <<: *airflow-depends -``` - -### Изменения для airflow-init: - -```yaml -airflow-init: - build: - context: . - dockerfile: Dockerfile.airflow - image: airflow-custom:latest - # Имя контейнера закомментировано (одноразовый сервис) - user: "0" - env_file: .env - environment: - <<: *airflow-env - volumes: - <<: *airflow-volumes - command: > - bash -lc " - set -e; - mkdir -p /opt/airflow/data && chown -R airflow:root /opt/airflow/data; - for i in {1..30}; do - su -s /bin/bash airflow -c \"PATH='/home/airflow/.local/bin:$${PATH}' airflow db migrate\" && break || echo 'waiting for pgmeta' && sleep 3; - done; - su -s /bin/bash airflow -c \"PATH='/home/airflow/.local/bin:$${PATH}' airflow users create --username ${AIRFLOW_USER} --password ${AIRFLOW_PASSWORD} --firstname Admin --lastname User --role Admin --email admin@example.org\" || true - " - depends_on: - pgmeta: - condition: service_healthy -``` - ---- - -## Часть 3: Влияние переименования контейнеров - -### Важно: Переименование контейнеров - -При переименовании контейнеров (`gp_airflow_web` → `gp_airflow_webserver`, `gp_airflow_sch` → `gp_airflow_scheduler`) необходимо учитывать: - -1. **Имена сервисов в docker-compose.yml остаются без изменений** - - `airflow-webserver`, `airflow-scheduler`, `airflow-init` - это имена сервисов - - Они используются в командах `docker-compose logs`, `docker-compose restart` и т.д. - - Эти имена НЕ меняются - -2. **Имена контейнеров меняются** - - `gp_airflow_web` → `gp_airflow_webserver` - - `gp_airflow_sch` → `gp_airflow_scheduler` - - Они используются в командах `docker exec`, `docker inspect`, `docker logs` - -3. **Проверка использования старых имён контейнеров** - ```bash - # Поиск упоминаний старых имён в скриптах - grep -r "gp_airflow_web" . - grep -r "gp_airflow_sch" . - ``` - -4. **Обновление Makefile (если используется)** - - Проверить команды, которые используют старые имена контейнеров - - Пример: `make logs` может использовать `docker-compose logs airflow-webserver` (это корректно) - -5. **Обновление документации** - - Обновить все упоминания имён контейнеров в README.md, TESTING.md - - Обновить примеры команд в документации - ---- - -## Часть 4: План тестирования изменений - -### Этап 1: Подготовка окружения - -1. **Остановить текущие контейнеры** - ```bash - make down - ``` - -2. **Удалить старые образы (опционально)** - ```bash - docker rmi airflow-custom:latest - ``` - -3. **Проверить наличие .env файла** - ```bash - ls -la .env - # Если отсутствует, скопировать из .env.example - cp .env.example .env - ``` - -### Этап 2: Сборка новых образов - -1. **Собрать образы с новым Dockerfile** - ```bash - docker-compose build - ``` - -2. **Проверить успешность сборки** - ```bash - docker images | grep airflow-custom - ``` - -3. **Проверить наличие LABEL** - ```bash - docker inspect airflow-custom:latest | grep -A 10 "Labels" - ``` - -### Этап 3: Запуск сервисов - -1. **Запустить весь стек** - ```bash - make up - ``` - -2. **Проверить статус контейнеров** - ```bash - docker-compose ps - ``` - -3. **Проверить логи инициализации** - ```bash - docker-compose logs airflow-init - ``` - -### Этап 4: Проверка healthcheck - -1. **Проверить healthcheck для airflow-webserver** - ```bash - docker inspect gp_airflow_webserver | grep -A 20 "Health" - ``` - -2. **Дождаться healthy статуса** - ```bash - watch -n 2 'docker inspect --format="{{.State.Health.Status}}" gp_airflow_webserver' - ``` - -### Этап 5: Функциональное тестирование - -1. **Проверить доступ к Airflow UI** - - Открыть http://localhost:8080 - - Проверить авторизацию (использовать креды из .env) - - Проверить наличие DAG'ов в списке - -2. **Проверить запуск DAG'ов** - - Выбрать любой DAG (например, `csv_to_greenplum`) - - Запустить вручную через UI - - Проверить успешность выполнения задач - -3. **Проверить подключения к БД** - - Проверить Admin → Connections - - Убедиться, что `greenplum_conn` и `bookings_db` доступны - - Проверить Test Connection для каждого подключения - -4. **Проверить логи scheduler** - ```bash - docker-compose logs airflow-scheduler | tail -50 - ``` - -### Этап 6: Проверка использования старых имён контейнеров - -1. **Поиск упоминаний старых имён в проекте** - ```bash - grep -r "gp_airflow_web" . --exclude-dir=.git --exclude-dir=__pycache__ - grep -r "gp_airflow_sch" . --exclude-dir=.git --exclude-dir=__pycache__ - ``` - -2. **Проверка Makefile** - ```bash - grep -E "gp_airflow_web|gp_airflow_sch" Makefile - ``` - -3. **Проверка документации** - ```bash - grep -r "gp_airflow_web" README.md TESTING.md AGENTS.md docs/ - grep -r "gp_airflow_sch" README.md TESTING.md AGENTS.md docs/ - ``` - -4. **Обновление найденных упоминаний** - - Заменить `gp_airflow_web` на `gp_airflow_webserver` - - Заменить `gp_airflow_sch` на `gp_airflow_scheduler` - - Обновить примеры команд в документации - -### Этап 7: Тестирование требований - -1. **Проверить установленные пакеты** - ```bash - docker exec gp_airflow_webserver pip list | grep -E "psycopg2|pandas" - ``` - -2. **Проверить совместимость пакетов** - ```bash - docker exec gp_airflow_webserver pip check - ``` - -3. **Проверить пользователя внутри контейнера** - ```bash - docker exec gp_airflow_webserver whoami - # Ожидаемый результат: airflow - ``` - -### Этап 8: Тестирование изменений requirements.txt - -1. **Добавить тестовый пакет в requirements.txt** - ```bash - echo "requests==2.31.0" >> airflow/requirements.txt - ``` - -2. **Перезапустить контейнеры** - ```bash - docker-compose restart airflow-webserver airflow-scheduler - ``` - -3. **Проверить установку пакета** - ```bash - docker exec gp_airflow_webserver pip list | grep requests - ``` - -4. **Удалить тестовый пакет** - ```bash - # Вернуть исходный requirements.txt - git checkout airflow/requirements.txt - ``` - -### Этап 9: Регрессионное тестирование - -1. **Запустить существующие тесты** - ```bash - make test - ``` - -2. **Проверить smoke-тесты DAG** - ```bash - uv run pytest tests/test_dags_smoke.py -v - ``` - -3. **Проверить тесты helpers** - ```bash - uv run pytest tests/test_greenplum_helpers.py -v - ``` - -### Этап 10: Проверка после перезапуска - -1. **Полный перезапуск стека** - ```bash - make down - make up - ``` - -2. **Проверить сохранность данных** - - Проверить наличие DAG'ов в UI - - Проверить историю запусков DAG'ов - - Проверить подключения к БД - -3. **Проверить логи на наличие ошибок** - ```bash - docker-compose logs airflow-webserver | grep -i error - docker-compose logs airflow-scheduler | grep -i error - # Обратите внимание: имена сервисов не изменились, только имена контейнеров - ``` - ---- - -## Часть 5: Критерии успеха - -### Функциональные требования: - -- ✅ Все контейнеры успешно запускаются -- ✅ Airflow UI доступен на http://localhost:8080 -- ✅ Авторизация работает корректно -- ✅ Все DAG'ы отображаются в UI -- ✅ DAG'и успешно выполняются -- ✅ Подключения к Greenplum и bookings-db работают -- ✅ Healthcheck для airflow-webserver работает корректно - -### Технические требования: - -- ✅ Образ собирается без ошибок -- ✅ LABEL присутствуют в образе -- ✅ Пакеты устанавливаются от пользователя airflow -- ✅ `pip check` не возвращает ошибок -- ✅ requirements.txt монтируется как volume -- ✅ YAML anchors работают корректно -- ✅ Нет дублирования конфигурации - -### Требования к совместимости: - -- ✅ Существующие тесты проходят успешно -- ✅ Данные сохраняются после перезапуска -- ✅ История запусков DAG'ов сохраняется -- ✅ Подключения к БД работают как раньше - ---- - -## Часть 6: Откат изменений - -Если изменения вызывают проблемы, план отката: - -1. **Остановить контейнеры** - ```bash - make down - ``` - -2. **Вернуть старые файлы** - ```bash - git checkout Dockerfile docker-compose.yml - ``` - -3. **Удалить новый образ** - ```bash - docker rmi airflow-custom:latest - ``` - -4. **Перезапустить стек** - ```bash - make up - ``` - ---- - -## Часть 7: Документация - -После успешного внедрения изменений необходимо обновить: - -1. **README.md** - добавить информацию о новом Dockerfile -2. **AGENTS.md** - обновить инструкции для агентов -3. **Комментарии в docker-compose.yml** - добавить пояснения к YAML anchors - ---- - -## Резюме - -План включает: -- 2 файла для изменения: `Dockerfile` → `Dockerfile.airflow`, `docker-compose.yml` -- 10 этапов тестирования -- 12 функциональных критериев успеха -- План отката на случай проблем -- Переименование контейнеров для улучшения читаемости -- Проверка использования старых имён контейнеров в проекте - -### Важные замечания: - -1. **Имена сервисов НЕ меняются** - `airflow-webserver`, `airflow-scheduler`, `airflow-init` -2. **Имена контейнеров меняются** - `gp_airflow_web` → `gp_airflow_webserver`, `gp_airflow_sch` → `gp_airflow_scheduler` -3. **Необходимо проверить** использование старых имён контейнеров в скриптах и документации -4. **Makefile команды** используют имена сервисов, поэтому они продолжат работать без изменений - -Все изменения следуют best practices для Docker и Airflow, сохраняют обратную совместимость и не требуют изменений в DAG'ах. diff --git a/plans/greenplum-pxf-custom-image-plan.md b/plans/greenplum-pxf-custom-image-plan.md deleted file mode 100644 index 10ce36a..0000000 --- a/plans/greenplum-pxf-custom-image-plan.md +++ /dev/null @@ -1,142 +0,0 @@ -# План работ: свой образ Greenplum с интегрированным PXF - -## Контекст и проблема - -Иногда (и у нас воспроизводится стабильно) контейнер `greenplum` падает при старте с ошибкой: - -`chown: changing ownership of '/docker-entrypoint-initdb.d/10_pxf_bookings.sh': Read-only file system` - -Причина: init-скрипт `pxf/init/10_pxf_bookings.sh` проброшен в контейнер как bind-mount `:ro`, а entrypoint базового образа пытается сделать `chown` файлов в `/docker-entrypoint-initdb.d/`. - -## Цели - -- Greenplum стабильно стартует после любого `restart/up` (без “иногда не стартует”). -- PXF готов к работе **после каждого запуска** контейнера. -- Не меняем права/владельца файлов в репозитории на хосте (никаких “файл стал root’ом / не редактируется”). -- Для студентов всё остаётся простым: `make build` (явная сборка) + `make up` (поднимает стенд; если образа нет — Docker Compose соберёт сам). - -## Выбранный подход (высокоуровневый дизайн) - -1) Делаем свой образ Greenplum на базе `woblerr/greenplum:6.27.1`. - -2) Встраиваем в образ “seed” для PXF: - - JDBC-драйвер (JAR), - - конфиг сервера `bookings-db` (`jdbc-site.xml`), - - скрипт “ensure”, который идемпотентно гарантирует, что файлы лежат в `PXF_BASE` (на persistent volume). - -3) Запускаем “ensure” **на каждом старте контейнера** через wrapper-entrypoint, а затем передаём управление оригинальному entrypoint базового образа. - -4) `PXF_BASE` по умолчанию остаётся на volume (`/data/pxf`), чтобы настройки переживали рестарты. - -## План работ (по шагам) - -### Шаг 1. Разведка базового образа - -- Проверить, где находится оригинальный entrypoint и как он запускается (путь, параметры, пользователь). -- Понять, как `GREENPLUM_PXF_ENABLE=true` влияет на старт (чтобы wrapper не ломал поведение). - -Результат: фиксируем в README “как устроен старт” (1–2 абзаца). - -### Шаг 2. Новый Dockerfile для Greenplum - -- Добавить `Dockerfile.greenplum`: - - `FROM woblerr/greenplum:6.27.1` - - `COPY` seed-артефакты в образ (например, в `/opt/pxf-seed/...`) - - `COPY` wrapper-entrypoint в образ - - настроить права/владельца внутри образа так, чтобы старт был без ошибок - -Результат: образ собирается локально через `make build` и/или автоматически через `make up`. - -### Шаг 3. Wrapper-entrypoint (каждый старт) - -- Добавить скрипт entrypoint-обёртки (например, `greenplum/entrypoint-wrapper.sh` или `pxf/entrypoint-wrapper.sh`): - - на старте вызывает `ensure`-скрипт; - - затем делает `exec` оригинального entrypoint базового образа с теми же аргументами. - -Важно: wrapper не должен “перехватывать” логику инициализации кластера — только добавлять шаг подготовки PXF. - -### Шаг 4. Переписать текущий init-скрипт в “ensure” (идемпотентный) - -- Превратить `pxf/init/10_pxf_bookings.sh` в скрипт, который можно безопасно выполнять на каждом запуске: - - не опираться на `~/.bashrc`; - - `PXF_BASE` вычислять через env (`PXF_BASE`, `GREENPLUM_DATA_DIRECTORY`, fallback `/data/pxf`); - - seed-копирование делать “если файла нет”; - - добавить понятные логи (что сделано / что пропущено); - - `pxf cluster sync`: - - выполнять, только если команда доступна, - - не валить контейнер при ошибке (но писать предупреждение). - -Дополнительно (опционально, но полезно для стенда): -- env-переключатель `PXF_SEED_OVERWRITE=1` — принудительно перезаписывать конфиг из образа в volume (для обновлений без удаления volume). - -### Шаг 5. Обновить `docker-compose.yml` - -- Для сервиса `greenplum` перейти на `build:` (и при желании оставить `image:` как тег). -- Убрать bind-mount’ы PXF (jar/config/init-скрипт), т.к. теперь всё в образе. -- Оставить `greenplum_data:/data` и `./sql:/sql:ro`. -- Исправить healthcheck Greenplum (сейчас конструкция вида `... || echo 1` делает healthcheck “вечно успешным”): - - healthcheck должен возвращать ненулевой код, если БД не готова; - - добавить `start_period`, чтобы не ловить ложные падения на холодном старте. - -### Шаг 6. Обновить Makefile и README - -- `Makefile`: - - убедиться, что `make build` собирает также Greenplum-образ (если введём build для сервиса); - - оставить `make up` как есть (Compose сам соберёт образ, если его нет). -- `README.md`: - - зачем свой образ (устойчивость, права на хосте, меньше mount’ов), - - как пересобрать образ, - - как обновить PXF-конфиг (через rebuild + `PXF_SEED_OVERWRITE=1` или через очистку volume). - -## Проверка и критерии готовности - -- `docker compose up -d greenplum` → контейнер остаётся `Up`, не падает. -- `docker compose restart greenplum` повторить 10–20 раз → без падений. -- Healthcheck Greenplum становится `healthy` (не “вечно healthy” и не “вечно starting”). -- Поднятие всего стека (`make up`) приводит к старту Airflow (scheduler/webserver), т.к. `depends_on: condition: service_healthy` начинает работать корректно. - -## Риски и как их снизить - -- **Стартап может замедлиться**, если `pxf cluster sync` делать каждый раз: поэтому скрипт должен быть быстрым, а sync — не фатальным при ошибках. -- **Обновление конфигов**: так как PXF_BASE на volume, изменения в образе сами не перетрут файлы — поэтому нужен `PXF_SEED_OVERWRITE=1` или понятная инструкция “как обновить”. - -## Откат - -- Вернуться к использованию `image: woblerr/greenplum:6.27.1` в `docker-compose.yml`. -- Вернуть mount’ы PXF, если нужно (но это вернёт риск с `:ro`). - ---- - -## Статус (реализовано) - -- Добавлен кастомный образ Greenplum: `Dockerfile.greenplum` (seed PXF + startup wrapper). -- Ensure‑скрипт перенесён в образ и стал идемпотентным: `pxf/init/10_pxf_bookings.sh`. -- Стартовый скрипт контейнера включает ensure и создаёт `EXTENSION pxf`: `pxf/init/start_greenplum_with_pxf.sh`. -- В `docker-compose.yml`: - - `greenplum` собирается через `build: Dockerfile.greenplum`; - - добавлен `hostname: gpdbsne`; - - убраны PXF bind-mount’ы (jar/config/init); - - healthcheck ждёт не только GPDB, но и готовность PXF. -- Обновлены инструкции: `README.md`, `.env.example`. -- Проблема с генератором demodb (пустая `bookings.bookings`) зафиксирована в `TODO.md`. - -## Проверка (как воспроизвести) - -Команды для ручной проверки: - -- Пересобрать и перезапустить Greenplum: - - `make build` - - `docker compose up -d --force-recreate greenplum` -- 3–10 рестартов: - - `docker compose restart greenplum` - - дождаться `healthy` в `docker compose ps` -- Проверить PXF: - - `docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/pxf/bin/pxf cluster status'"` -- Проверить DAG, который использует PXF: - - `docker exec -i gp_airflow_scheduler airflow dags test bookings_stg_ddl` - -Текущий результат: - -- `bookings_stg_ddl` проходит (PXF и `protocol pxf` доступны). -- `bookings_to_gp_stage` падает не из-за PXF, а из-за пустого источника - (`demo.bookings.bookings` = 0 строк). Это отдельная задача (см. `TODO.md`). diff --git a/pxf/servers/bookings-db/jdbc-site.xml b/pxf/servers/bookings-db/jdbc-site.xml index dca82e9..6172ce5 100644 --- a/pxf/servers/bookings-db/jdbc-site.xml +++ b/pxf/servers/bookings-db/jdbc-site.xml @@ -27,5 +27,10 @@ bookings + + jdbc.column.types + jsonb=TEXT,tstzrange=TEXT,_int4=TEXT + + diff --git a/scripts/e2e_etl.sh b/scripts/e2e_etl.sh new file mode 100755 index 0000000..2a840b7 --- /dev/null +++ b/scripts/e2e_etl.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Скрипт автоматизированного прогона E2E-теста всей цепочки DWH через REST API Airflow. +# Отрабатывает 2 "учебных дня" для проверки инкрементальной загрузки. + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +warn() { echo ":: ${1}" >&2; } + +# Загружаем переменные из .env, если файл существует, иначе используем дефолты +if [ -f ".env" ]; then + export $(grep -v '^#' .env | xargs) +fi + +AIRFLOW_USER=${AIRFLOW_USER:-admin} +AIRFLOW_PASSWORD=${AIRFLOW_PASSWORD:-admin} +API_URL="http://localhost:8080/api/v1/dags" + +# Функция для триггера DAG и ожидания его завершения +trigger_and_wait() { + local dag_id=$1 + warn "Запуск $dag_id через REST API..." + + # 0. Unpause DAG + curl -s -X PATCH "$API_URL/$dag_id" \ + --user "$AIRFLOW_USER:$AIRFLOW_PASSWORD" \ + -H "Content-Type: application/json" \ + -d '{"is_paused": false}' > /dev/null + + # 1. Trigger + local response + response=$(curl -s -X POST "$API_URL/$dag_id/dagRuns" \ + --user "$AIRFLOW_USER:$AIRFLOW_PASSWORD" \ + -H "Content-Type: application/json" \ + -d '{}') + + local run_id + run_id=$(echo "$response" | grep -o '"dag_run_id": "[^"]*' | cut -d'"' -f4) + if [ -z "$run_id" ]; then + echo "Ошибка запуска $dag_id. Ответ API: $response" >&2 + exit 1 + fi + warn "Успешный триггер. Run ID: $run_id" + + # 2. Wait + warn "Ожидание завершения $dag_id..." + local attempts=0 + local max_attempts=120 # 10 минут (120 * 5 сек) + + while true; do + local status + status=$(curl -s "$API_URL/$dag_id/dagRuns/$run_id" \ + --user "$AIRFLOW_USER:$AIRFLOW_PASSWORD" | grep -o '"state": "[^"]*' | cut -d'"' -f4) + + if [ "$status" = "success" ]; then + warn "DAG $dag_id завершен: SUCCESS" + break + elif [ "$status" = "failed" ]; then + echo "DAG $dag_id УПАЛ (FAILED)!" >&2 + exit 1 + elif [ "$status" = "queued" ] || [ "$status" = "running" ] || [ "$status" = "" ]; then + attempts=$((attempts + 1)) + if [ "$attempts" -ge "$max_attempts" ]; then + echo "Таймаут ожидания $dag_id!" >&2 + exit 1 + fi + sleep 5 + else + echo "Неизвестный статус: $status" >&2 + exit 1 + fi + done +} + +warn "=== DDL: Создание схем и таблиц ===" +trigger_and_wait "bookings_stg_ddl" +trigger_and_wait "bookings_ods_ddl" +trigger_and_wait "bookings_dds_ddl" +trigger_and_wait "bookings_dm_ddl" + +warn "=== DAY 1: Initial Load ===" +trigger_and_wait "bookings_to_gp_stage" +trigger_and_wait "bookings_to_gp_ods" +trigger_and_wait "bookings_to_gp_dds" +trigger_and_wait "bookings_to_gp_dm" + +warn "=== DAY 2: Increment ===" +trigger_and_wait "bookings_to_gp_stage" +trigger_and_wait "bookings_to_gp_ods" +trigger_and_wait "bookings_to_gp_dds" +trigger_and_wait "bookings_to_gp_dm" + +warn "=== Верификация данных ===" +# Выполняем проверки строк и бизнес-логики, чтобы убедиться, что все витрины DM-слоя заполнены +docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c \"psql -d gp_dwh -c \\\" +SELECT 'STG bookings' as layer, COUNT(*) FROM stg.bookings UNION ALL +SELECT 'ODS bookings', COUNT(*) FROM ods.bookings UNION ALL +SELECT 'DDS fact', COUNT(*) FROM dds.fact_flight_sales UNION ALL +SELECT 'DM sales_report', 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 UNION ALL +SELECT 'DM airport_traffic', COUNT(*) FROM dm.airport_traffic UNION ALL +SELECT 'DM monthly_overview', COUNT(*) FROM dm.monthly_overview; + +-- Дополнительная проверка бизнес-логики (load factor не должен быть NULL и должен быть в пределах разумного) +SELECT + 'Check LF' as check, + COUNT(*) as total_rows, + SUM(CASE WHEN avg_load_factor IS NOT NULL AND avg_load_factor >= 0 THEN 1 ELSE 0 END) as valid_lf +FROM dm.monthly_overview; +\\\"\"" + +warn "E2E ETL тест успешно завершен!" diff --git a/scripts/e2e_smoke.sh b/scripts/e2e_smoke.sh index 66d3962..950ef5d 100755 --- a/scripts/e2e_smoke.sh +++ b/scripts/e2e_smoke.sh @@ -38,30 +38,10 @@ wait_up airflow-scheduler warn "Init demo DB bookings" make bookings-init -warn "Apply DDL to Greenplum" -make ddl-gp - warn "Run local pytest suite" make test -warn "Airflow DAG test: csv_to_greenplum" -docker compose -f docker-compose.yml exec airflow-webserver airflow dags test csv_to_greenplum 2024-01-01 - -warn "Check orders count in Greenplum" -ORDERS_COUNT=$(docker compose -f docker-compose.yml exec greenplum bash -lc "su - gpadmin -c \"/usr/local/greenplum-db/bin/psql -t -A -d gp_dwh -c 'SELECT COUNT(*) FROM public.orders;'\"") -if [ "${ORDERS_COUNT:-0}" -le 0 ]; then - echo "orders table is empty after csv_to_greenplum (COUNT=${ORDERS_COUNT:-0})" >&2 - exit 1 -fi - -warn "Airflow DAG test: bookings_to_gp_stage" -docker compose -f docker-compose.yml exec airflow-webserver airflow dags test bookings_to_gp_stage 2024-01-01 - -warn "Check stg.bookings count in Greenplum" -BOOKINGS_COUNT=$(docker compose -f docker-compose.yml exec greenplum bash -lc "su - gpadmin -c \"/usr/local/greenplum-db/bin/psql -t -A -d gp_dwh -c 'SELECT COUNT(*) FROM stg.bookings;'\"") -if [ "${BOOKINGS_COUNT:-0}" -le 0 ]; then - echo "stg.bookings is empty after bookings_to_gp_stage (COUNT=${BOOKINGS_COUNT:-0})" >&2 - exit 1 -fi +warn "Start full E2E ETL test via REST API" +make e2e-etl warn "Smoke test completed successfully" diff --git a/sql/base/orders_ddl.sql b/sql/base/orders_ddl.sql deleted file mode 100644 index c1bfac6..0000000 --- a/sql/base/orders_ddl.sql +++ /dev/null @@ -1,11 +0,0 @@ --- DDL для базовой таблицы orders, которую использует CSV‑pipeline. --- Выполняется идемпотентно: таблица создаётся, если ещё не существует. - -CREATE TABLE IF NOT EXISTS public.orders ( - order_id BIGINT, - order_ts TIMESTAMP NOT NULL, - customer_id BIGINT NOT NULL, - amount NUMERIC(12,2) NOT NULL -) -WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -DISTRIBUTED BY (order_id); diff --git a/sql/ddl_gp.sql b/sql/ddl_gp.sql index 0e81d60..722d48a 100644 --- a/sql/ddl_gp.sql +++ b/sql/ddl_gp.sql @@ -1,6 +1,6 @@ -- Главный входной DDL-скрипт для Greenplum в учебном стенде. -- Выполняется из контейнера командой `make ddl-gp` и создаёт/обновляет --- все объекты, которые нужны базовым DAG (csv_to_greenplum, bookings_to_gp_stage); +-- все объекты, которые нужны DAG'ам bookings ETL-пайплайна; -- подключает файловые DDL через \i, чтобы сохранять единый входной скрипт. -- -- Чтобы не ломать задания, новые объекты лучше добавлять в отдельные файлы @@ -8,9 +8,6 @@ -- -- Подробнее про STG/bookings: см. docs/bookings_to_gp_stage.md. --- Таблица для CSV‑пайплайна (csv_to_greenplum). -\i base/orders_ddl.sql - -- Внешняя таблица для чтения данных из демо-БД bookings через PXF (JDBC). -- Источник: таблица bookings.bookings в базе demo (Postgres, сервис bookings-db). DROP EXTERNAL TABLE IF EXISTS public.ext_bookings_bookings; @@ -22,6 +19,45 @@ CREATE EXTERNAL TABLE public.ext_bookings_bookings ( LOCATION ('pxf://bookings.bookings?PROFILE=JDBC&SERVER=bookings-db') FORMAT 'CUSTOM' (formatter='pxfwritable_import'); --- DDL для слоя stg по таблице bookings вынесен в отдельный файл. --- Здесь подключаем его через psql \i, чтобы сохранить единый входной скрипт. +-- DDL для слоя stg по таблицам bookings и tickets вынесены в отдельные файлы. +-- Здесь подключаем их через psql \i, чтобы сохранить единый входной скрипт. \i stg/bookings_ddl.sql +\i stg/tickets_ddl.sql + +-- DDL для новых таблиц STG слоя (справочники) +\i stg/airports_ddl.sql +\i stg/airplanes_ddl.sql +\i stg/routes_ddl.sql +\i stg/seats_ddl.sql + +-- DDL для новых таблиц STG слоя (транзакции) +\i stg/flights_ddl.sql +\i stg/segments_ddl.sql +\i stg/boarding_passes_ddl.sql + +-- DDL для ODS-слоя (текущее состояние, SCD1). +\i ods/airports_ddl.sql +\i ods/airplanes_ddl.sql +\i ods/routes_ddl.sql +\i ods/seats_ddl.sql +\i ods/bookings_ddl.sql +\i ods/tickets_ddl.sql +\i ods/flights_ddl.sql +\i ods/segments_ddl.sql +\i ods/boarding_passes_ddl.sql + +-- DDL для DDS-слоя (Star Schema, SCD1 + SCD2). +\i dds/dim_calendar_ddl.sql +\i dds/dim_airports_ddl.sql +\i dds/dim_airplanes_ddl.sql +\i dds/dim_tariffs_ddl.sql +\i dds/dim_passengers_ddl.sql +\i dds/dim_routes_ddl.sql +\i dds/fact_flight_sales_ddl.sql + +-- DDL для DM-слоя (Data Mart). +\i dm/sales_report_ddl.sql +\i dm/route_performance_ddl.sql +\i dm/passenger_loyalty_ddl.sql +\i dm/airport_traffic_ddl.sql +\i dm/monthly_overview_ddl.sql diff --git a/sql/dds/dim_airplanes_ddl.sql b/sql/dds/dim_airplanes_ddl.sql new file mode 100644 index 0000000..2a9b13b --- /dev/null +++ b/sql/dds/dim_airplanes_ddl.sql @@ -0,0 +1,23 @@ +-- DDL для DDS-слоя по таблице dim_airplanes (SCD1-измерение). + +CREATE SCHEMA IF NOT EXISTS dds; + +-- Тип таблицы: Heap (стандартная). +-- Обоснование: Необходим row-level UPDATE для реализации SCD1 UPSERT. +-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы. +CREATE TABLE IF NOT EXISTS dds.dim_airplanes ( + airplane_sk INTEGER NOT NULL, + airplane_bk TEXT NOT NULL, + model TEXT NOT NULL, + range_km INTEGER, + speed_kmh INTEGER, + total_seats INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) +DISTRIBUTED BY (airplane_sk); + +COMMENT ON TABLE dds.dim_airplanes IS 'Измерение моделей самолетов (DDS).'; diff --git a/sql/dds/dim_airplanes_dq.sql b/sql/dds/dim_airplanes_dq.sql new file mode 100644 index 0000000..c0d01dd --- /dev/null +++ b/sql/dds/dim_airplanes_dq.sql @@ -0,0 +1,83 @@ +-- 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 $$; diff --git a/sql/dds/dim_airplanes_load.sql b/sql/dds/dim_airplanes_load.sql new file mode 100644 index 0000000..05999dd --- /dev/null +++ b/sql/dds/dim_airplanes_load.sql @@ -0,0 +1,96 @@ +-- Загрузка 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; diff --git a/sql/dds/dim_airports_ddl.sql b/sql/dds/dim_airports_ddl.sql new file mode 100644 index 0000000..d7566e3 --- /dev/null +++ b/sql/dds/dim_airports_ddl.sql @@ -0,0 +1,24 @@ +-- DDL для DDS-слоя по таблице dim_airports (SCD1-измерение). + +CREATE SCHEMA IF NOT EXISTS dds; + +-- Тип таблицы: Heap (стандартная). +-- Обоснование: Необходим row-level UPDATE для реализации SCD1 UPSERT. +-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы. +CREATE TABLE IF NOT EXISTS dds.dim_airports ( + airport_sk INTEGER NOT NULL, + airport_bk TEXT NOT NULL, + airport_name TEXT NOT NULL, + city TEXT NOT NULL, + country TEXT NOT NULL, + timezone TEXT NOT NULL, + coordinates TEXT, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) +DISTRIBUTED BY (airport_sk); + +COMMENT ON TABLE dds.dim_airports IS 'Измерение аэропортов (DDS).'; diff --git a/sql/dds/dim_airports_dq.sql b/sql/dds/dim_airports_dq.sql new file mode 100644 index 0000000..640951d --- /dev/null +++ b/sql/dds/dim_airports_dq.sql @@ -0,0 +1,88 @@ +-- DQ для DDS dim_airports. + +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_airports; + + IF v_row_count = 0 THEN + RAISE EXCEPTION 'DQ FAILED: dds.dim_airports пуста.'; + END IF; + + -- Нет дублей по SK. + SELECT COUNT(*) - COUNT(DISTINCT airport_sk) + INTO v_dup_sk + FROM dds.dim_airports; + + IF v_dup_sk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_airports найдены дубликаты airport_sk: %', + v_dup_sk; + END IF; + + -- Нет дублей по BK. + SELECT COUNT(*) - COUNT(DISTINCT airport_bk) + INTO v_dup_bk + FROM dds.dim_airports; + + IF v_dup_bk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_airports найдены дубликаты airport_bk: %', + v_dup_bk; + END IF; + + -- Покрытие ODS: все airport_code из ODS есть в DDS. + SELECT COUNT(*) + INTO v_missing_bk + FROM (SELECT DISTINCT airport_code FROM ods.airports) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_airports AS d + WHERE d.airport_bk = s.airport_code + ); + + IF v_missing_bk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_airports отсутствуют ключи из ods.airports: %', + v_missing_bk; + END IF; + + -- Обязательные поля. + SELECT COUNT(*) + INTO v_null_count + FROM dds.dim_airports + WHERE airport_sk IS NULL + OR airport_bk IS NULL + OR airport_bk = '' + OR airport_name IS NULL + OR airport_name = '' + OR city IS NULL + OR city = '' + OR country IS NULL + OR country = '' + OR timezone IS NULL + OR timezone = '' + 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_airports найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: dds.dim_airports ок, строк=%', + v_row_count; +END $$; diff --git a/sql/dds/dim_airports_load.sql b/sql/dds/dim_airports_load.sql new file mode 100644 index 0000000..d553a3f --- /dev/null +++ b/sql/dds/dim_airports_load.sql @@ -0,0 +1,67 @@ +-- Загрузка DDS dim_airports: SCD1 UPSERT (UPDATE изменившихся + INSERT новых). + +-- Statement 1: UPDATE существующих записей (если атрибуты изменились). +UPDATE dds.dim_airports AS d +SET airport_name = s.airport_name, + city = s.city, + country = s.country, + timezone = s.timezone, + coordinates = s.coordinates, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM ods.airports AS s +WHERE d.airport_bk = s.airport_code + AND ( + d.airport_name IS DISTINCT FROM s.airport_name + OR d.city IS DISTINCT FROM s.city + OR d.country IS DISTINCT FROM s.country + OR d.timezone IS DISTINCT FROM s.timezone + OR d.coordinates IS DISTINCT FROM s.coordinates + ); + +-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()). +-- Учебный комментарий: Генерация SK через MAX() + ROW_NUMBER() +-- Почему не SERIAL/IDENTITY? В MPP-базах данных (как Greenplum) sequence +-- работают через мастер-узел и могут стать узким местом при массовой вставке. +-- Паттерн MAX() + ROW_NUMBER() генерирует ключи распределённо на сегментах. +-- Этот подход работает безопасно только потому, что Airflow запускает +-- джобы загрузки для одной таблицы строго последовательно (concurrency=1). +-- При параллельной загрузке возникнет состояние гонки (race condition) и возможны дубли SK. +WITH max_sk AS ( + SELECT COALESCE(MAX(airport_sk), 0) AS v + FROM dds.dim_airports +) +INSERT INTO dds.dim_airports ( + airport_sk, + airport_bk, + airport_name, + city, + country, + timezone, + coordinates, + created_at, + updated_at, + _load_id, + _load_ts +) +SELECT + (SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.airport_code)::INTEGER, + s.airport_code, + s.airport_name, + s.city, + s.country, + s.timezone, + s.coordinates, + now(), + now(), + '{{ run_id }}', + now() +FROM ods.airports AS s +WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_airports AS d + WHERE d.airport_bk = s.airport_code +); + +ANALYZE dds.dim_airports; diff --git a/sql/dds/dim_calendar_ddl.sql b/sql/dds/dim_calendar_ddl.sql new file mode 100644 index 0000000..9eef562 --- /dev/null +++ b/sql/dds/dim_calendar_ddl.sql @@ -0,0 +1,20 @@ +-- DDL для DDS-слоя по таблице dim_calendar (статическое измерение дат). + +CREATE SCHEMA IF NOT EXISTS dds; + +-- Тип таблицы: Append-Only Row-oriented (zstd:1). +-- Обоснование: Статичные данные без обновлений. Обеспечивает эффективное сжатие. +CREATE TABLE IF NOT EXISTS dds.dim_calendar ( + calendar_sk INTEGER NOT NULL, + date_actual DATE NOT NULL, + year_actual INTEGER NOT NULL, + month_actual INTEGER NOT NULL, + day_actual INTEGER NOT NULL, + day_of_week INTEGER NOT NULL, + day_name TEXT NOT NULL, + is_weekend BOOLEAN NOT NULL +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +DISTRIBUTED BY (calendar_sk); + +COMMENT ON TABLE dds.dim_calendar IS 'Измерение календаря (DDS).'; diff --git a/sql/dds/dim_calendar_dq.sql b/sql/dds/dim_calendar_dq.sql new file mode 100644 index 0000000..f0c2089 --- /dev/null +++ b/sql/dds/dim_calendar_dq.sql @@ -0,0 +1,87 @@ +-- DQ для DDS dim_calendar. + +DO $$ +DECLARE + v_row_count BIGINT; + v_dup_sk BIGINT; + v_dup_date BIGINT; + v_null_count BIGINT; + v_missing_flight_dates BIGINT; +BEGIN + -- Таблица должна быть достаточно заполнена. + SELECT COUNT(*) + INTO v_row_count + FROM dds.dim_calendar; + + IF v_row_count < 1000 THEN + RAISE EXCEPTION + 'DQ FAILED: dds.dim_calendar содержит слишком мало строк: %', + v_row_count; + END IF; + + -- Нет дублей по surrogate key. + SELECT COUNT(*) - COUNT(DISTINCT calendar_sk) + INTO v_dup_sk + FROM dds.dim_calendar; + + IF v_dup_sk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_calendar найдены дубликаты calendar_sk: %', + v_dup_sk; + END IF; + + -- Нет дублей по business key (date_actual). + SELECT COUNT(*) - COUNT(DISTINCT date_actual) + INTO v_dup_date + FROM dds.dim_calendar; + + IF v_dup_date <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_calendar найдены дубликаты date_actual: %', + v_dup_date; + END IF; + + -- Обязательные поля не NULL. + SELECT COUNT(*) + INTO v_null_count + FROM dds.dim_calendar + WHERE calendar_sk IS NULL + OR date_actual IS NULL + OR year_actual IS NULL + OR month_actual IS NULL + OR day_actual IS NULL + OR day_of_week IS NULL + OR day_name IS NULL + OR day_name = '' + OR is_weekend IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_calendar найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + -- Календарь покрывает даты вылета из ODS (где scheduled_departure не NULL). + SELECT COUNT(*) + INTO v_missing_flight_dates + FROM ( + SELECT DISTINCT f.scheduled_departure::DATE AS departure_date + FROM ods.flights AS f + WHERE f.scheduled_departure IS NOT NULL + ) AS src + WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_calendar AS c + WHERE c.date_actual = src.departure_date + ); + + IF v_missing_flight_dates <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: dds.dim_calendar не покрывает даты вылета из ods.flights: %', + v_missing_flight_dates; + END IF; + + RAISE NOTICE + 'DQ PASSED: dds.dim_calendar ок, строк=%', + v_row_count; +END $$; diff --git a/sql/dds/dim_calendar_load.sql b/sql/dds/dim_calendar_load.sql new file mode 100644 index 0000000..1c9e31f --- /dev/null +++ b/sql/dds/dim_calendar_load.sql @@ -0,0 +1,29 @@ +-- Загрузка DDS dim_calendar: статическое измерение (генерация дат). +-- Заполняем только если таблица пуста (идемпотентно). + +INSERT INTO dds.dim_calendar ( + calendar_sk, + date_actual, + year_actual, + month_actual, + day_actual, + day_of_week, + day_name, + is_weekend +) +SELECT + ROW_NUMBER() OVER (ORDER BY d.date_actual)::INTEGER AS calendar_sk, + d.date_actual, + EXTRACT(YEAR FROM d.date_actual)::INTEGER AS year_actual, + EXTRACT(MONTH FROM d.date_actual)::INTEGER AS month_actual, + EXTRACT(DAY FROM d.date_actual)::INTEGER AS day_actual, + EXTRACT(ISODOW FROM d.date_actual)::INTEGER AS day_of_week, + TO_CHAR(d.date_actual, 'FMDay') AS day_name, + EXTRACT(ISODOW FROM d.date_actual) IN (6, 7) AS is_weekend +FROM ( + SELECT generate_series('2016-01-01'::DATE, '2030-12-31'::DATE, '1 day'::INTERVAL)::DATE + AS date_actual +) AS d +WHERE NOT EXISTS (SELECT 1 FROM dds.dim_calendar LIMIT 1); + +ANALYZE dds.dim_calendar; diff --git a/sql/dds/dim_passengers_ddl.sql b/sql/dds/dim_passengers_ddl.sql new file mode 100644 index 0000000..90a0afc --- /dev/null +++ b/sql/dds/dim_passengers_ddl.sql @@ -0,0 +1,20 @@ +-- DDL для DDS-слоя по таблице dim_passengers (SCD1-измерение). + +CREATE SCHEMA IF NOT EXISTS dds; + +-- Тип таблицы: Heap (стандартная). +-- Обоснование: Необходим row-level UPDATE для реализации SCD1 UPSERT. +-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы. +CREATE TABLE IF NOT EXISTS dds.dim_passengers ( + passenger_sk INTEGER NOT NULL, + passenger_id TEXT NOT NULL, + passenger_name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) +DISTRIBUTED BY (passenger_sk); + +COMMENT ON TABLE dds.dim_passengers IS 'Измерение пассажиров (DDS).'; diff --git a/sql/dds/dim_passengers_dq.sql b/sql/dds/dim_passengers_dq.sql new file mode 100644 index 0000000..990b365 --- /dev/null +++ b/sql/dds/dim_passengers_dq.sql @@ -0,0 +1,100 @@ +-- 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 $$; diff --git a/sql/dds/dim_passengers_load.sql b/sql/dds/dim_passengers_load.sql new file mode 100644 index 0000000..9d06bcd --- /dev/null +++ b/sql/dds/dim_passengers_load.sql @@ -0,0 +1,67 @@ +-- Загрузка 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; diff --git a/sql/dds/dim_routes_ddl.sql b/sql/dds/dim_routes_ddl.sql new file mode 100644 index 0000000..dfd07bb --- /dev/null +++ b/sql/dds/dim_routes_ddl.sql @@ -0,0 +1,40 @@ +-- DDL для DDS-слоя по таблице dim_routes (SCD2-измерение). + +CREATE SCHEMA IF NOT EXISTS dds; + +-- Тип таблицы: Heap (стандартная). +-- Обоснование: Необходим row-level UPDATE для реализации SCD2 (закрытие версий). +-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы. +-- +-- Учебный комментарий (Kimball Star Schema): +-- Измерение должно быть «самодостаточным»: один JOIN к dim_routes — +-- и аналитик видит маршрут, города, модель самолёта и кол-во мест. +-- Денормализованные атрибуты (departure_city, arrival_city, airplane_model, total_seats) +-- НЕ участвуют в hashdiff. Версия SCD2 фиксирует изменения атрибутов маршрута +-- (аэропорт, самолёт, расписание). Если изменится название города — +-- обновим отдельным refresh-шагом, не создавая новую версию. +CREATE TABLE IF NOT EXISTS dds.dim_routes ( + route_sk INTEGER NOT NULL, + route_bk TEXT NOT NULL, + departure_airport TEXT NOT NULL, + arrival_airport TEXT NOT NULL, + airplane_code TEXT NOT NULL, + departure_city TEXT NOT NULL, + arrival_city TEXT NOT NULL, + airplane_model TEXT NOT NULL, + total_seats INTEGER NOT NULL, + days_of_week TEXT, + departure_time TIME, + duration INTERVAL, + hashdiff TEXT NOT NULL, + valid_from DATE NOT NULL, + valid_to DATE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) +DISTRIBUTED BY (route_sk); + +COMMENT ON TABLE dds.dim_routes IS 'Измерение маршрутов (DDS).'; diff --git a/sql/dds/dim_routes_dq.sql b/sql/dds/dim_routes_dq.sql new file mode 100644 index 0000000..6e1a6bd --- /dev/null +++ b/sql/dds/dim_routes_dq.sql @@ -0,0 +1,157 @@ +-- 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 $$; diff --git a/sql/dds/dim_routes_load.sql b/sql/dds/dim_routes_load.sql new file mode 100644 index 0000000..c95266b --- /dev/null +++ b/sql/dds/dim_routes_load.sql @@ -0,0 +1,144 @@ +-- Загрузка 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; + diff --git a/sql/dds/dim_tariffs_ddl.sql b/sql/dds/dim_tariffs_ddl.sql new file mode 100644 index 0000000..f14cc89 --- /dev/null +++ b/sql/dds/dim_tariffs_ddl.sql @@ -0,0 +1,18 @@ +-- DDL для DDS-слоя по таблице dim_tariffs (SCD1-измерение). + +CREATE SCHEMA IF NOT EXISTS dds; + +-- Тип таблицы: Append-Only Row-oriented (zstd:1). +-- Обоснование: Редко дополняемые данные без обновлений. Обеспечивает эффективное сжатие. +CREATE TABLE IF NOT EXISTS dds.dim_tariffs ( + tariff_sk INTEGER NOT NULL, + fare_conditions TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +DISTRIBUTED BY (tariff_sk); + +COMMENT ON TABLE dds.dim_tariffs IS 'Измерение тарифов (DDS).'; diff --git a/sql/dds/dim_tariffs_dq.sql b/sql/dds/dim_tariffs_dq.sql new file mode 100644 index 0000000..925bba9 --- /dev/null +++ b/sql/dds/dim_tariffs_dq.sql @@ -0,0 +1,98 @@ +-- DQ для DDS dim_tariffs. + +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_tariffs. + SELECT COUNT(*) + INTO v_row_count + FROM dds.dim_tariffs; + + SELECT COUNT(DISTINCT fare_conditions) + INTO v_src_count + FROM ods.segments + WHERE fare_conditions IS NOT NULL + AND fare_conditions <> ''; + + IF v_row_count = 0 AND v_src_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: dds.dim_tariffs пуста при непустом источнике ods.segments (fare_conditions=%).', + v_src_count; + ELSIF v_row_count = 0 AND v_src_count = 0 THEN + RAISE NOTICE + 'DQ PASSED: dds.dim_tariffs пуста, т.к. в ods.segments нет тарифов для загрузки.'; + RETURN; + END IF; + + -- Нет дублей по SK. + SELECT COUNT(*) - COUNT(DISTINCT tariff_sk) + INTO v_dup_sk + FROM dds.dim_tariffs; + + IF v_dup_sk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_tariffs найдены дубликаты tariff_sk: %', + v_dup_sk; + END IF; + + -- Нет дублей по BK. + SELECT COUNT(*) - COUNT(DISTINCT fare_conditions) + INTO v_dup_bk + FROM dds.dim_tariffs; + + IF v_dup_bk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_tariffs найдены дубликаты fare_conditions: %', + v_dup_bk; + END IF; + + -- Покрытие ODS: все fare_conditions из ods.segments есть в DDS. + SELECT COUNT(*) + INTO v_missing_bk + FROM ( + SELECT DISTINCT fare_conditions + FROM ods.segments + WHERE fare_conditions IS NOT NULL + AND fare_conditions <> '' + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_tariffs AS d + WHERE d.fare_conditions = s.fare_conditions + ); + + IF v_missing_bk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_tariffs отсутствуют значения fare_conditions из ods.segments: %', + v_missing_bk; + END IF; + + -- Обязательные поля. + SELECT COUNT(*) + INTO v_null_count + FROM dds.dim_tariffs + WHERE tariff_sk IS NULL + OR fare_conditions IS NULL + OR fare_conditions = '' + 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_tariffs найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: dds.dim_tariffs ок, строк=%', + v_row_count; +END $$; diff --git a/sql/dds/dim_tariffs_load.sql b/sql/dds/dim_tariffs_load.sql new file mode 100644 index 0000000..cf4b87c --- /dev/null +++ b/sql/dds/dim_tariffs_load.sql @@ -0,0 +1,40 @@ +-- Загрузка DDS dim_tariffs: SCD1 UPSERT (INSERT новых тарифов). + +WITH src AS ( + SELECT DISTINCT + s.fare_conditions + FROM ods.segments AS s + WHERE s.fare_conditions IS NOT NULL + AND s.fare_conditions <> '' +), +-- Учебный комментарий: Генерация SK через MAX() + ROW_NUMBER() +-- Этот подход работает безопасно только потому, что Airflow запускает +-- джобы загрузки для одной таблицы строго последовательно (concurrency=1). +-- При параллельной загрузке возникнет состояние гонки (race condition) и возможны дубли SK. +max_sk AS ( + SELECT COALESCE(MAX(tariff_sk), 0) AS v + FROM dds.dim_tariffs +) +INSERT INTO dds.dim_tariffs ( + tariff_sk, + fare_conditions, + created_at, + updated_at, + _load_id, + _load_ts +) +SELECT + (SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.fare_conditions)::INTEGER, + s.fare_conditions, + now(), + now(), + '{{ run_id }}', + now() +FROM src AS s +WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_tariffs AS d + WHERE d.fare_conditions = s.fare_conditions +); + +ANALYZE dds.dim_tariffs; diff --git a/sql/dds/fact_flight_sales_ddl.sql b/sql/dds/fact_flight_sales_ddl.sql new file mode 100644 index 0000000..ba449f7 --- /dev/null +++ b/sql/dds/fact_flight_sales_ddl.sql @@ -0,0 +1,34 @@ +-- DDL для DDS-слоя по таблице fact_flight_sales. + +CREATE SCHEMA IF NOT EXISTS dds; + +-- Учебный комментарий: Почему у факта нет своего суррогатного ключа (fact_sk)? +-- В классическом DWH (Кимбалл) таблица фактов идентифицируется набором её +-- измерений или дегенеративных ключей (в нашем случае: ticket_no + flight_id). +-- Добавление отдельного ID только тратит место и не несёт аналитической ценности. + +-- Тип таблицы: Heap (стандартная). +-- Обоснование: Необходим row-level UPDATE для обновления статусов (is_boarded). +-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы. +CREATE TABLE IF NOT EXISTS dds.fact_flight_sales ( + calendar_sk INTEGER, + departure_airport_sk INTEGER, + arrival_airport_sk INTEGER, + airplane_sk INTEGER, + tariff_sk INTEGER, + passenger_sk INTEGER, + route_sk INTEGER, + book_ref TEXT NOT NULL, + ticket_no TEXT NOT NULL, + flight_id INTEGER NOT NULL, + book_date DATE, + seat_no TEXT, + price NUMERIC(10,2), + is_boarded BOOLEAN NOT NULL, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) +DISTRIBUTED BY (ticket_no); + +COMMENT ON TABLE dds.fact_flight_sales IS 'Факт продаж билетов (DDS).'; diff --git a/sql/dds/fact_flight_sales_dq.sql b/sql/dds/fact_flight_sales_dq.sql new file mode 100644 index 0000000..c4fa53c --- /dev/null +++ b/sql/dds/fact_flight_sales_dq.sql @@ -0,0 +1,151 @@ +-- DQ для DDS fact_flight_sales. + +DO $$ +DECLARE + v_row_count BIGINT; + v_ods_count BIGINT; + v_dup_count BIGINT; + v_null_passenger BIGINT; + v_null_tariff BIGINT; + v_null_route_related BIGINT; + v_null_calendar BIGINT; + v_null_required BIGINT; +BEGIN + -- Для пустого инкрементального окна (ods.segments) допускаем пустой факт. + SELECT COUNT(*) + INTO v_ods_count + FROM ods.segments; + + SELECT COUNT(*) + INTO v_row_count + FROM dds.fact_flight_sales; + + IF v_ods_count = 0 THEN + IF v_row_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: ods.segments пустая, но в dds.fact_flight_sales есть строки: %', + v_row_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.segments и dds.fact_flight_sales пустые (инкрементальное окно без сегментов).'; + RETURN; + END IF; + + IF v_row_count = 0 THEN + RAISE EXCEPTION + 'DQ FAILED: dds.fact_flight_sales пуста при непустом источнике ods.segments (%).', + v_ods_count; + END IF; + + -- Покрытие: количество строк = ods.segments. + IF v_row_count <> v_ods_count THEN + RAISE EXCEPTION + 'DQ FAILED: dds.fact_flight_sales (%) <> ods.segments (%). Потеряны строки.', + v_row_count, + v_ods_count; + END IF; + + -- Нет дублей по зерну. + SELECT COUNT(*) + INTO v_dup_count + FROM ( + SELECT ticket_no, flight_id + FROM dds.fact_flight_sales + GROUP BY ticket_no, flight_id + HAVING COUNT(*) > 1 + ) AS d; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.fact_flight_sales дубликаты (ticket_no, flight_id): %', + v_dup_count; + END IF; + + -- Ссылочная целостность: passenger_sk. + 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: %', + v_null_passenger; + END IF; + + -- Ссылочная целостность: tariff_sk. + SELECT COUNT(*) + INTO v_null_tariff + FROM dds.fact_flight_sales + WHERE tariff_sk IS NULL; + + IF v_null_tariff <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales строки без tariff_sk: %', + v_null_tariff; + END IF; + + -- Route-related FK: допустимо при аномалиях, фейлим если > 1%. + SELECT COUNT(*) + INTO v_null_route_related + 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; + + IF v_null_route_related > 0 THEN + IF v_null_route_related * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales слишком много строк с NULL в route-related FK: % (>1%%)', + v_null_route_related; + ELSE + RAISE NOTICE + 'DQ WARNING: в fact_flight_sales строк с NULL в route-related FK: % (<=1%%, допустимо)', + v_null_route_related; + END IF; + END IF; + + -- Calendar: допустимо если scheduled_departure IS NULL, фейлим если > 1%. + SELECT COUNT(*) + INTO v_null_calendar + FROM dds.fact_flight_sales + WHERE calendar_sk IS NULL; + + IF v_null_calendar > 0 THEN + IF v_null_calendar * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales слишком много строк без calendar_sk: % (>1%%)', + v_null_calendar; + ELSE + RAISE NOTICE + 'DQ WARNING: в fact_flight_sales строк без calendar_sk: % (<=1%%, допустимо)', + v_null_calendar; + END IF; + END IF; + + -- Обязательные поля. + SELECT COUNT(*) + INTO v_null_required + FROM dds.fact_flight_sales + WHERE book_ref IS NULL + OR book_ref = '' + OR ticket_no IS NULL + OR ticket_no = '' + OR flight_id IS NULL + OR is_boarded IS NULL + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_required <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales NULL обязательные поля: %', + v_null_required; + END IF; + + RAISE NOTICE + 'DQ PASSED: dds.fact_flight_sales ок, строк=%', + v_row_count; +END $$; diff --git a/sql/dds/fact_flight_sales_load.sql b/sql/dds/fact_flight_sales_load.sql new file mode 100644 index 0000000..d61f357 --- /dev/null +++ b/sql/dds/fact_flight_sales_load.sql @@ -0,0 +1,129 @@ +-- Загрузка DDS fact_flight_sales: инкрементальный UPSERT по (ticket_no, flight_id). + +-- Statement 1: UPDATE существующих строк факта. +-- Обновляем только мутабельные поля; SK измерений не перезаписываем. +UPDATE dds.fact_flight_sales AS f +SET seat_no = bp.seat_no, + price = seg.amount, + is_boarded = (bp.ticket_no IS NOT NULL), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM ods.segments AS seg +LEFT JOIN ods.boarding_passes AS bp + ON bp.ticket_no = seg.ticket_no + AND bp.flight_id = seg.flight_id +WHERE f.ticket_no = seg.ticket_no + AND f.flight_id = seg.flight_id + AND ( + f.is_boarded IS DISTINCT FROM (bp.ticket_no IS NOT NULL) + OR f.price IS DISTINCT FROM seg.amount + OR f.seat_no IS DISTINCT FROM bp.seat_no + ); + +-- Statement 2: INSERT новых строк факта. +-- Dimension SK фиксируются на момент вставки (point-in-time для SCD2 routes). +WITH fact_src AS ( + SELECT + seg.ticket_no, + seg.flight_id, + cal.calendar_sk, + dep.airport_sk AS departure_airport_sk, + arr.airport_sk AS arrival_airport_sk, + ap.airplane_sk, + tar.tariff_sk, + pax.passenger_sk, + rte.route_sk, + tkt.book_ref, + bkg.book_date::DATE AS book_date, + bp.seat_no, + seg.amount AS price, + (bp.ticket_no IS NOT NULL) AS is_boarded + FROM ods.segments AS seg + JOIN ods.tickets AS tkt + ON tkt.ticket_no = seg.ticket_no + JOIN ods.bookings AS bkg + ON bkg.book_ref = tkt.book_ref + JOIN ods.flights AS flt + ON flt.flight_id = seg.flight_id + -- Учебный комментарий: Late-arriving dimensions (Опаздывающие измерения) + -- Мы используем LEFT JOIN, так как факт (рейс/билет) может прийти раньше, + -- чем справочник (пассажир/маршрут) обновится в DDS. + -- + -- Airport lookup через ods.routes (эталонный справочник), а не через dds.dim_routes + -- (студенческое задание SCD2). Это архитектурное решение: эталонный пайплайн работает + -- независимо от студенческого кода. Аэропорты вылета/прилёта одинаковы во всех + -- версиях одного route_no — безопасно брать из ODS без point-in-time логики. + -- ROW_NUMBER по validity DESC: выбираем актуальную версию расписания маршрута. + 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_calendar AS cal + ON cal.date_actual = flt.scheduled_departure::DATE + 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, как прежде) + LEFT JOIN dds.dim_tariffs AS tar + ON tar.fare_conditions = seg.fare_conditions + LEFT JOIN dds.dim_passengers AS pax + ON pax.passenger_id = tkt.passenger_id + LEFT JOIN ods.boarding_passes AS bp + ON bp.ticket_no = seg.ticket_no + AND bp.flight_id = seg.flight_id +) +INSERT INTO dds.fact_flight_sales ( + calendar_sk, + departure_airport_sk, + arrival_airport_sk, + airplane_sk, + tariff_sk, + passenger_sk, + route_sk, + book_ref, + ticket_no, + flight_id, + book_date, + seat_no, + price, + is_boarded, + _load_id, + _load_ts +) +SELECT + s.calendar_sk, + s.departure_airport_sk, + s.arrival_airport_sk, + s.airplane_sk, + s.tariff_sk, + s.passenger_sk, + s.route_sk, + s.book_ref, + s.ticket_no, + s.flight_id, + s.book_date, + s.seat_no, + s.price, + s.is_boarded, + '{{ run_id }}', + now() +FROM fact_src AS s +WHERE NOT EXISTS ( + SELECT 1 + FROM dds.fact_flight_sales AS f + WHERE f.ticket_no = s.ticket_no + AND f.flight_id = s.flight_id +); + +ANALYZE dds.fact_flight_sales; diff --git a/sql/dm/airport_traffic_ddl.sql b/sql/dm/airport_traffic_ddl.sql new file mode 100644 index 0000000..4d4479e --- /dev/null +++ b/sql/dm/airport_traffic_ddl.sql @@ -0,0 +1,45 @@ +-- DDL для витрины dm.airport_traffic (Пассажиропоток аэропортов). +-- +-- Учебные цели: +-- 1. Обработка dual-role dimensions: один аэропорт выступает и как точка вылета, +-- и как точка прилета. Витрина собирает статистику в едином разрезе (date, airport). +-- 2. Денормализация: включение кода аэропорта (BK) и города для удобства анализа. +-- 3. Предупреждение о семантике данных: риск "двойного счета" выручки. +-- 4. Стратегия HWM по датам: пересчет только затронутых дней. + +CREATE TABLE IF NOT EXISTS dm.airport_traffic ( + -- Зерно: дата и суррогатный ключ аэропорта + traffic_date DATE NOT NULL, + airport_sk INTEGER NOT NULL, + + -- Денормализованные атрибуты (из dim_airports) + airport_bk TEXT NOT NULL, + city TEXT NOT NULL, + + -- Метрики вылета + departures_flights INTEGER NOT NULL DEFAULT 0, + departures_passengers INTEGER NOT NULL DEFAULT 0, + departures_revenue NUMERIC(15,2) NOT NULL DEFAULT 0, + + -- Метрики прилета + arrivals_flights INTEGER NOT NULL DEFAULT 0, + arrivals_passengers INTEGER NOT NULL DEFAULT 0, + arrivals_revenue NUMERIC(15,2) NOT NULL DEFAULT 0, + + -- Итоговые метрики + total_passengers INTEGER NOT NULL DEFAULT 0, + + -- Служебные поля + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) -- Используем Heap для инкрементального UPSERT +DISTRIBUTED BY (airport_sk); + +COMMENT ON TABLE dm.airport_traffic IS 'Витрина: ежедневный пассажиропоток аэропортов (UNION ALL, Heap)'; + +-- Комментарии к колонкам с предупреждением (учебная ценность) +COMMENT ON COLUMN dm.airport_traffic.departures_revenue IS 'Выручка от билетов, где аэропорт был точкой вылета. ВНИМАНИЕ: суммирование с arrivals_revenue приведет к двойному счету!'; +COMMENT ON COLUMN dm.airport_traffic.arrivals_revenue IS 'Выручка от билетов, где аэропорт был точкой прилета. ВНИМАНИЕ: суммирование с departures_revenue приведет к двойному счету!'; diff --git a/sql/dm/airport_traffic_dq.sql b/sql/dm/airport_traffic_dq.sql new file mode 100644 index 0000000..bb8021b --- /dev/null +++ b/sql/dm/airport_traffic_dq.sql @@ -0,0 +1,49 @@ +-- 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 $$; diff --git a/sql/dm/airport_traffic_load.sql b/sql/dm/airport_traffic_load.sql new file mode 100644 index 0000000..214113e --- /dev/null +++ b/sql/dm/airport_traffic_load.sql @@ -0,0 +1,131 @@ +-- Загрузка витрины 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; diff --git a/sql/dm/monthly_overview_ddl.sql b/sql/dm/monthly_overview_ddl.sql new file mode 100644 index 0000000..80f6304 --- /dev/null +++ b/sql/dm/monthly_overview_ddl.sql @@ -0,0 +1,40 @@ +-- DDL для витрины dm.monthly_overview (Помесячная сводка). +-- +-- Учебные цели: +-- 1. Двухуровневая агрегация (до рейса, затем до месяца) для точного расчета +-- средних показателей (load factor), избегая парадокса Симпсона. +-- 2. Выбор ключа распределения (Distribution Key): +-- Мы используем airplane_sk, а НЕ (year, month). Распределение по датам в MPP — +-- это антипаттерн, приводящий к Data Skew (весь месяц на одном сегменте). + +CREATE TABLE IF NOT EXISTS dm.monthly_overview ( + -- Зерно: Месяц и самолет + year_actual INTEGER NOT NULL, + month_actual INTEGER NOT NULL, + airplane_sk INTEGER NOT NULL, + + -- Денормализованные атрибуты + airplane_bk TEXT NOT NULL, + airplane_model TEXT NOT NULL, + total_seats INTEGER NOT NULL, + + -- Метрики + total_flights INTEGER NOT NULL DEFAULT 0, + total_tickets INTEGER NOT NULL DEFAULT 0, + total_boarded INTEGER NOT NULL DEFAULT 0, + total_revenue NUMERIC(15,2) NOT NULL DEFAULT 0, + avg_ticket_price NUMERIC(10,2), + avg_load_factor NUMERIC(5,4), -- Точный load factor (сначала по рейсу, потом среднее) + unique_routes INTEGER NOT NULL DEFAULT 0, + unique_passengers INTEGER NOT NULL DEFAULT 0, + + -- Служебные + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) -- Heap для UPSERT +DISTRIBUTED BY (airplane_sk); -- Избегаем распределения по (year, month) + +COMMENT ON TABLE dm.monthly_overview IS 'Витрина: помесячная аналитика с точным load_factor (Двухуровневая агрегация, Heap)'; diff --git a/sql/dm/monthly_overview_dq.sql b/sql/dm/monthly_overview_dq.sql new file mode 100644 index 0000000..7ca896c --- /dev/null +++ b/sql/dm/monthly_overview_dq.sql @@ -0,0 +1,52 @@ +-- 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 $$; diff --git a/sql/dm/monthly_overview_load.sql b/sql/dm/monthly_overview_load.sql new file mode 100644 index 0000000..7a0aa89 --- /dev/null +++ b/sql/dm/monthly_overview_load.sql @@ -0,0 +1,167 @@ +-- Загрузка витрины 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; diff --git a/sql/dm/passenger_loyalty_ddl.sql b/sql/dm/passenger_loyalty_ddl.sql new file mode 100644 index 0000000..e16ce5c --- /dev/null +++ b/sql/dm/passenger_loyalty_ddl.sql @@ -0,0 +1,41 @@ +-- DDL для витрины dm.passenger_loyalty (Лояльность пассажиров). +-- +-- Учебные цели: +-- 1. Выбор зерна для SCD1-измерений: +-- В SCD2-витринах (напр. route_performance) зерно — бизнес-ключ (BK), т.к. версий много. +-- В SCD1 (dim_passengers) BK и SK связаны 1:1, поэтому зерном выступает суррогатный ключ (SK). +-- Это упрощает JOIN с фактами и ускоряет запросы в Greenplum. +-- 2. Использование Heap-таблицы для UPSERT: +-- В отличие от AO Column, Heap поддерживает эффективный UPDATE, что критично для +-- накопительных витрин с большим количеством строк. +-- 3. Служебные поля жизненного цикла: +-- Т.к. мы будем обновлять метрики пассажиров, нам нужны и created_at, и updated_at. + +CREATE TABLE IF NOT EXISTS dm.passenger_loyalty ( + -- Ключ: суррогатный ключ пассажира (SCD1 гарантирует 1:1 к бизнес-ключу) + passenger_sk INTEGER NOT NULL, + passenger_bk TEXT NOT NULL, + passenger_name TEXT NOT NULL, + + -- Метрики лояльности + total_bookings INTEGER NOT NULL, -- кол-во бронирований (book_ref) + total_flights INTEGER NOT NULL, -- кол-во перелетов + total_boarded INTEGER NOT NULL, -- кол-во успешных посадок + total_spent NUMERIC(15,2) NOT NULL, + avg_ticket_price NUMERIC(10,2), + favorite_fare_conditions TEXT, -- самый частый класс обслуживания + unique_routes INTEGER NOT NULL, -- кол-во уникальных маршрутов + first_flight_date DATE, + last_flight_date DATE, + days_as_customer INTEGER, -- стаж клиента (дней между первым и последним) + + -- Служебные поля + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) -- Явно указываем Heap для поддержки эффективного UPDATE +DISTRIBUTED BY (passenger_sk); + +COMMENT ON TABLE dm.passenger_loyalty IS 'Витрина: лояльность и активность пассажиров (Incremental UPSERT, Heap)'; diff --git a/sql/dm/passenger_loyalty_dq.sql b/sql/dm/passenger_loyalty_dq.sql new file mode 100644 index 0000000..6e57d32 --- /dev/null +++ b/sql/dm/passenger_loyalty_dq.sql @@ -0,0 +1,51 @@ +-- 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 $$; diff --git a/sql/dm/passenger_loyalty_load.sql b/sql/dm/passenger_loyalty_load.sql new file mode 100644 index 0000000..fb8e3ad --- /dev/null +++ b/sql/dm/passenger_loyalty_load.sql @@ -0,0 +1,131 @@ +-- Загрузка витрины 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; diff --git a/sql/dm/route_performance_ddl.sql b/sql/dm/route_performance_ddl.sql new file mode 100644 index 0000000..eb1e895 --- /dev/null +++ b/sql/dm/route_performance_ddl.sql @@ -0,0 +1,48 @@ +-- DDL для витрины dm.route_performance (Эффективность маршрутов). +-- +-- Учебные цели: +-- 1. Демонстрация AO Column Store (Append-Only Columnar Storage). +-- В Greenplum формат AO Column идеален для аналитики (сжатие, чтение только нужных колонок). +-- 2. Демонстрация стратегии Full Rebuild (TRUNCATE + INSERT). +-- Для небольших справочных витрин это проще и надёжнее, чем сложный инкремент. +-- 3. Выбор служебных полей: +-- Для Full Rebuild таблиц created_at/updated_at не имеют смысла, т.к. строки +-- каждый раз пересоздаются. Достаточно _load_ts. + +CREATE TABLE IF NOT EXISTS dm.route_performance ( + -- Ключ: бизнес-код маршрута (напр. 'SVO-LED') + route_bk TEXT NOT NULL, + + -- SK текущей (актуальной) версии маршрута для связи с измерениями + route_sk INTEGER NOT NULL, + + -- Денормализованные атрибуты (из актуальной версии dim_routes) + departure_airport_bk TEXT NOT NULL, + departure_city TEXT NOT NULL, + arrival_airport_bk TEXT NOT NULL, + arrival_city TEXT NOT NULL, + airplane_bk TEXT NOT NULL, + airplane_model TEXT NOT NULL, + total_seats INTEGER NOT NULL, + + -- Метрики (агрегированы по всем версиям данного маршрута) + total_flights INTEGER NOT NULL, + total_tickets INTEGER NOT NULL, + total_boarded INTEGER NOT NULL, + total_revenue NUMERIC(15,2) NOT NULL, + avg_ticket_price NUMERIC(10,2), + avg_boarding_rate NUMERIC(5,4) NOT NULL, + avg_load_factor NUMERIC(5,4), -- средняя заполняемость кресел + first_flight_date DATE, + last_flight_date DATE, + + -- Служебные поля. + -- created_at/updated_at здесь не нужны: при Full Rebuild все строки пересоздаются, + -- поэтому _load_ts достаточно для отслеживания момента загрузки. + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=true, orientation=column, compresstype=zstd, compresslevel=1) +DISTRIBUTED BY (route_bk); + +COMMENT ON TABLE dm.route_performance IS 'Витрина: эффективность авиамаршрутов (Full Rebuild, AO Column, zstd)'; diff --git a/sql/dm/route_performance_dq.sql b/sql/dm/route_performance_dq.sql new file mode 100644 index 0000000..6c6c509 --- /dev/null +++ b/sql/dm/route_performance_dq.sql @@ -0,0 +1,59 @@ +-- 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 $$; diff --git a/sql/dm/route_performance_load.sql b/sql/dm/route_performance_load.sql new file mode 100644 index 0000000..c8fd3c3 --- /dev/null +++ b/sql/dm/route_performance_load.sql @@ -0,0 +1,83 @@ +-- Загрузка витрины 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; diff --git a/sql/dm/sales_report_ddl.sql b/sql/dm/sales_report_ddl.sql new file mode 100644 index 0000000..5739b08 --- /dev/null +++ b/sql/dm/sales_report_ddl.sql @@ -0,0 +1,65 @@ +-- DDL для DM-слоя: витрина sales_report. +-- +-- Бизнес-вопрос: "Какова выручка, кол-во билетов и boarding rate +-- по направлениям/тарифам за каждый день?" +-- +-- Паттерны для студентов: +-- - Денормализация измерений (города, аэропорты, тарифы) для удобства аналитики +-- - Служебные поля календаря (day_of_week, day_name, is_weekend) +-- +-- ВНИМАНИЕ (Антипаттерн распределения): +-- Никогда не распределяйте таблицы по дате (DISTRIBUTED BY flight_date) в MPP-системах! +-- 1. Load Skew: При инкрементальной загрузке весь батч за один день запишется на ОДИН сегмент, +-- а остальные будут простаивать. Кластер превратится в одиночный сервер. +-- 2. Processing Skew: Запросы аналитиков за конкретный день будут читаться только с одного сегмента. +-- +-- ПРАВИЛЬНЫЙ ВЫБОР: Распределение по полям с высокой кардинальностью (направления). +-- В нашем случае это комбинация departure_airport_sk и arrival_airport_sk. + +CREATE SCHEMA IF NOT EXISTS dm; + +-- Тип таблицы: Heap (стандартная). +-- Обоснование: Необходим row-level UPDATE для реализации инкрементального UPSERT по HWM. +-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы. +CREATE TABLE IF NOT EXISTS dm.sales_report ( + -- Ключ (зерно витрины) + flight_date DATE NOT NULL, + departure_airport_sk INTEGER NOT NULL, + arrival_airport_sk INTEGER NOT NULL, + tariff_sk INTEGER NOT NULL, + + -- Денормализованные атрибуты (для удобства аналитики) + departure_city TEXT NOT NULL, + departure_airport_bk TEXT NOT NULL, + arrival_city TEXT NOT NULL, + arrival_airport_bk TEXT NOT NULL, + fare_conditions TEXT NOT NULL, + + -- Атрибуты календаря + day_of_week INTEGER NOT NULL, + day_name TEXT NOT NULL, + is_weekend BOOLEAN NOT NULL, + + -- Метрики + tickets_sold INTEGER NOT NULL, + passengers_boarded INTEGER NOT NULL, + total_revenue NUMERIC(15,2) NOT NULL, + avg_price NUMERIC(10,2) NOT NULL, + min_price NUMERIC(10,2), + max_price NUMERIC(10,2), + boarding_rate NUMERIC(5,4) NOT NULL, -- boarded / sold + + -- Служебные поля (канон из naming_conventions.md) + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) +DISTRIBUTED BY (departure_airport_sk, arrival_airport_sk); + +-- Комментарии для документирования +COMMENT ON TABLE dm.sales_report IS 'Витрина продаж: выручка, билеты и boarding rate по направлениям/тарифам/дням.'; + +COMMENT ON COLUMN dm.sales_report.boarding_rate IS + 'Доля пассажиров, прошедших посадку (passengers_boarded / tickets_sold)'; diff --git a/sql/dm/sales_report_dq.sql b/sql/dm/sales_report_dq.sql new file mode 100644 index 0000000..f88e6ba --- /dev/null +++ b/sql/dm/sales_report_dq.sql @@ -0,0 +1,144 @@ +-- DQ для DM витрины sales_report. +-- +-- Проверки: +-- 1. Таблица не пуста (если источник за затронутые батчем дни не пуст) +-- 2. Нет дублей по составному ключу +-- 3. Бизнес-инварианты: tickets_sold >= passengers_boarded, boarding_rate BETWEEN 0 AND 1 +-- 4. Обязательные поля не NULL +-- +-- ВАЖНО (Паттерн Data Quality): +-- Проверки выполняются ИНКРЕМЕНТАЛЬНО. Мы спрашиваем саму витрину, какие даты +-- были обновлены текущим запуском DAG-а (по _load_id = run_id), и валидируем +-- только эти даты. Это корректно, потому что на шаге load мы записали +-- текущий run_id DM-DAG-а в обновлённые строки витрины. + +DO $$ +DECLARE + v_row_count BIGINT; + v_src_count BIGINT; + v_dup_count BIGINT; + v_invalid_boarding BIGINT; + v_null_required BIGINT; +BEGIN + -- Создаем временную таблицу с датами, которые МЫ обновили в этом запуске DAG-а. + -- Спрашиваем саму витрину (не DDS!), потому что на шаге load мы записали + -- _load_id = '{{ run_id }}' текущего DM-DAG-а в обновлённые строки. + CREATE TEMP TABLE tmp_dq_affected_dates ON COMMIT DROP AS + SELECT DISTINCT flight_date AS date_actual + FROM dm.sales_report + WHERE _load_id = '{{ run_id }}'; + + -- Если батч пуст (ничего не обновлялось), нам нечего валидировать. + IF NOT EXISTS (SELECT 1 FROM tmp_dq_affected_dates) THEN + RAISE NOTICE 'DQ PASSED: Витрина не обновлялась в этом запуске (%). Новых данных в DDS нет.', '{{ run_id }}'; + RETURN; + END IF; + + -- Проверка 1: Таблица не пуста при непустом источнике (для затронутых дат) + SELECT COUNT(*) + INTO v_src_count + FROM dds.fact_flight_sales AS f + JOIN dds.dim_calendar AS cal + ON cal.calendar_sk = f.calendar_sk + WHERE cal.date_actual IN (SELECT date_actual FROM tmp_dq_affected_dates); + + SELECT COUNT(*) + INTO v_row_count + FROM dm.sales_report + WHERE flight_date IN (SELECT date_actual FROM tmp_dq_affected_dates); + + IF v_src_count > 0 AND v_row_count = 0 THEN + RAISE EXCEPTION + 'DQ FAILED: dm.sales_report пуста для дат батча при непустом источнике (% строк в fact_flight_sales)', + v_src_count; + END IF; + + RAISE NOTICE 'DQ INFO: dm.sales_report содержит % строк для дат текущего батча', v_row_count; + + -- Проверка 2: Нет дублей по составному ключу (в рамках затронутых дат) + SELECT COUNT(*) + INTO v_dup_count + FROM ( + SELECT flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk + FROM dm.sales_report + WHERE flight_date IN (SELECT date_actual FROM tmp_dq_affected_dates) + GROUP BY flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk + HAVING COUNT(*) > 1 + ) AS dups; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: Найдено % дублирующихся комбинаций ключа в dm.sales_report', + v_dup_count; + END IF; + + RAISE NOTICE 'DQ PASSED: Дублей по составному ключу для дат батча нет'; + + -- Проверка 3: Бизнес-инварианты (в рамках затронутых дат) + + -- tickets_sold >= passengers_boarded + SELECT COUNT(*) + INTO v_invalid_boarding + FROM dm.sales_report + WHERE flight_date IN (SELECT date_actual FROM tmp_dq_affected_dates) + AND tickets_sold < passengers_boarded; + + IF v_invalid_boarding <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: % строк с tickets_sold < passengers_boarded', + v_invalid_boarding; + END IF; + + -- boarding_rate BETWEEN 0 AND 1 + SELECT COUNT(*) + INTO v_invalid_boarding + FROM dm.sales_report + WHERE flight_date IN (SELECT date_actual FROM tmp_dq_affected_dates) + AND (boarding_rate < 0 OR boarding_rate > 1); + + IF v_invalid_boarding <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: % строк с boarding_rate вне диапазона [0, 1]', + v_invalid_boarding; + END IF; + + -- total_revenue >= 0 + SELECT COUNT(*) + INTO v_invalid_boarding + FROM dm.sales_report + WHERE flight_date IN (SELECT date_actual FROM tmp_dq_affected_dates) + AND total_revenue < 0; + + IF v_invalid_boarding <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: % строк с отрицательной total_revenue', + v_invalid_boarding; + END IF; + + RAISE NOTICE 'DQ PASSED: Бизнес-инварианты соблюдены'; + + -- Проверка 4: Обязательные поля не NULL (в рамках затронутых дат) + SELECT COUNT(*) + INTO v_null_required + FROM dm.sales_report + WHERE flight_date IN (SELECT date_actual FROM tmp_dq_affected_dates) + AND ( + departure_airport_sk IS NULL + OR arrival_airport_sk IS NULL + OR tariff_sk IS NULL + OR tickets_sold IS NULL + OR total_revenue IS NULL + OR boarding_rate IS NULL + ); + + IF v_null_required <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: % строк с NULL в обязательных полях', + v_null_required; + END IF; + + RAISE NOTICE 'DQ PASSED: Обязательные поля заполнены'; + + -- Итог + RAISE NOTICE 'DQ COMPLETE: dm.sales_report прошла все проверки для текущего батча'; +END $$; diff --git a/sql/dm/sales_report_load.sql b/sql/dm/sales_report_load.sql new file mode 100644 index 0000000..8f8a6f8 --- /dev/null +++ b/sql/dm/sales_report_load.sql @@ -0,0 +1,166 @@ +-- Загрузка DM витрины sales_report: инкрементальный UPSERT. +-- +-- Паттерны для студентов: +-- - Использование TEMP TABLE: агрегация считается только ОДИН раз (канон для MPP) +-- - Инкрементальность (HWM — High-Water Mark): витрина сравнивает свой MAX(_load_ts) +-- с _load_ts фактов в DDS и пересчитывает агрегаты только для затронутых дат. +-- Это делает конвейер самовосстанавливающимся: если DAG не запускался несколько дней, +-- при следующем запуске витрина автоматически «догонит» всю накопленную дельту. +-- - Денормализация: города и названия тарифов тащим в витрину +-- - UPSERT: UPDATE изменившихся + INSERT новых (нужен heap для UPDATE) +-- - IS DISTINCT FROM для корректного сравнения NULL + +-- Шаг 1: Считаем агрегаты для дельты (новых данных с момента последнего обновления витрины). +-- ON COMMIT DROP гарантирует, что таблица исчезнет после завершения транзакции (Airflow сессии). +CREATE TEMP TABLE tmp_sales_report_delta ON COMMIT DROP AS +SELECT + cal.date_actual AS flight_date, + dep.airport_sk AS departure_airport_sk, + arr.airport_sk AS arrival_airport_sk, + tar.tariff_sk, + + -- Денормализованные атрибуты + dep.city AS departure_city, + dep.airport_bk AS departure_airport_bk, + arr.city AS arrival_city, + arr.airport_bk AS arrival_airport_bk, + tar.fare_conditions, + + -- Атрибуты календаря + cal.day_of_week, + cal.day_name, + cal.is_weekend, + + -- Метрики + COUNT(*) AS tickets_sold, + SUM(CASE WHEN f.is_boarded THEN 1 ELSE 0 END) AS passengers_boarded, + SUM(f.price) AS total_revenue, + AVG(f.price) AS avg_price, + MIN(f.price) AS min_price, + MAX(f.price) AS max_price, + -- boarding_rate: делим boarded на sold с защитой от деления на 0 + ROUND( + SUM(CASE WHEN f.is_boarded THEN 1 ELSE 0 END)::NUMERIC / NULLIF(COUNT(*), 0), + 4 + ) AS boarding_rate + +FROM dds.fact_flight_sales AS f +JOIN dds.dim_calendar AS cal + ON cal.calendar_sk = f.calendar_sk +JOIN dds.dim_airports AS dep + ON dep.airport_sk = f.departure_airport_sk +JOIN dds.dim_airports AS arr + ON arr.airport_sk = f.arrival_airport_sk +JOIN dds.dim_tariffs AS tar + ON tar.tariff_sk = f.tariff_sk +WHERE cal.date_actual IN ( + -- ИНКРЕМЕНТАЛЬНЫЙ ФИЛЬТР (HWM — High-Water Mark): + -- Ищем даты полётов, в которых появились новые или изменённые факты + -- с момента последнего обновления витрины (MAX(_load_ts) в dm.sales_report). + -- Если витрина пуста — '1900-01-01' заберёт всю историю (первичная загрузка). + SELECT DISTINCT cal_sq.date_actual + FROM dds.fact_flight_sales AS f_sq + JOIN dds.dim_calendar AS cal_sq ON f_sq.calendar_sk = cal_sq.calendar_sk + WHERE f_sq._load_ts > ( + SELECT COALESCE(MAX(_load_ts), '1900-01-01'::TIMESTAMP) + FROM dm.sales_report + ) +) +GROUP BY + cal.date_actual, + dep.airport_sk, dep.city, dep.airport_bk, + arr.airport_sk, arr.city, arr.airport_bk, + tar.tariff_sk, tar.fare_conditions, + cal.day_of_week, cal.day_name, cal.is_weekend +DISTRIBUTED BY (departure_airport_sk, arrival_airport_sk); + +-- Шаг 2: UPDATE существующих строк. +-- Обновляем метрики и денормализованные атрибуты. +UPDATE dm.sales_report AS tgt +SET + departure_city = src.departure_city, + arrival_city = src.arrival_city, + fare_conditions = src.fare_conditions, + day_of_week = src.day_of_week, + day_name = src.day_name, + is_weekend = src.is_weekend, + tickets_sold = src.tickets_sold, + passengers_boarded = src.passengers_boarded, + total_revenue = src.total_revenue, + avg_price = src.avg_price, + min_price = src.min_price, + max_price = src.max_price, + boarding_rate = src.boarding_rate, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM tmp_sales_report_delta AS src +WHERE tgt.flight_date = src.flight_date + AND tgt.departure_airport_sk = src.departure_airport_sk + AND tgt.arrival_airport_sk = src.arrival_airport_sk + AND tgt.tariff_sk = src.tariff_sk + AND ( + -- Обновляем только если что-то реально изменилось + tgt.tickets_sold IS DISTINCT FROM src.tickets_sold + OR tgt.passengers_boarded IS DISTINCT FROM src.passengers_boarded + OR tgt.total_revenue IS DISTINCT FROM src.total_revenue + OR tgt.boarding_rate IS DISTINCT FROM src.boarding_rate + OR tgt.departure_city IS DISTINCT FROM src.departure_city + OR tgt.arrival_city IS DISTINCT FROM src.arrival_city + ); + +-- Шаг 3: INSERT новых строк (те, которых нет по составному ключу). +INSERT INTO dm.sales_report ( + flight_date, + departure_airport_sk, + arrival_airport_sk, + tariff_sk, + departure_city, + departure_airport_bk, + arrival_city, + arrival_airport_bk, + fare_conditions, + day_of_week, + day_name, + is_weekend, + tickets_sold, + passengers_boarded, + total_revenue, + avg_price, + min_price, + max_price, + boarding_rate, + _load_id +) +SELECT + src.flight_date, + src.departure_airport_sk, + src.arrival_airport_sk, + src.tariff_sk, + src.departure_city, + src.departure_airport_bk, + src.arrival_city, + src.arrival_airport_bk, + src.fare_conditions, + src.day_of_week, + src.day_name, + src.is_weekend, + src.tickets_sold, + src.passengers_boarded, + src.total_revenue, + src.avg_price, + src.min_price, + src.max_price, + src.boarding_rate, + '{{ run_id }}' AS _load_id +FROM tmp_sales_report_delta AS src +WHERE NOT EXISTS ( + SELECT 1 + FROM dm.sales_report AS tgt + WHERE tgt.flight_date = src.flight_date + AND tgt.departure_airport_sk = src.departure_airport_sk + AND tgt.arrival_airport_sk = src.arrival_airport_sk + AND tgt.tariff_sk = src.tariff_sk +); + +ANALYZE dm.sales_report; diff --git a/sql/ods/airplanes_ddl.sql b/sql/ods/airplanes_ddl.sql new file mode 100644 index 0000000..b2b2ec3 --- /dev/null +++ b/sql/ods/airplanes_ddl.sql @@ -0,0 +1,19 @@ +-- DDL для ODS-слоя по таблице airplanes (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +-- Тип таблицы: Append-Only Row-oriented (zstd:1). +-- Обоснование: Используется паттерн TRUNCATE+INSERT (полный снимок). +-- Для узких таблиц Row-store производительнее Column-store при чтении всей строки. +CREATE TABLE IF NOT EXISTS ods.airplanes ( + airplane_code TEXT NOT NULL, + model TEXT NOT NULL, + range_km INTEGER, + speed_kmh INTEGER, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +DISTRIBUTED BY (airplane_code); + +COMMENT ON TABLE ods.airplanes IS 'Справочник моделей самолетов (ODS).'; diff --git a/sql/ods/airplanes_dq.sql b/sql/ods/airplanes_dq.sql new file mode 100644 index 0000000..ee9b081 --- /dev/null +++ b/sql/ods/airplanes_dq.sql @@ -0,0 +1,99 @@ +-- 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 $$; diff --git a/sql/ods/airplanes_load.sql b/sql/ods/airplanes_load.sql new file mode 100644 index 0000000..48c1b19 --- /dev/null +++ b/sql/ods/airplanes_load.sql @@ -0,0 +1,39 @@ +-- Загрузка 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; diff --git a/sql/ods/airports_ddl.sql b/sql/ods/airports_ddl.sql new file mode 100644 index 0000000..5db43e2 --- /dev/null +++ b/sql/ods/airports_ddl.sql @@ -0,0 +1,21 @@ +-- DDL для ODS-слоя по таблице airports (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +-- Тип таблицы: Append-Only Row-oriented (zstd:1). +-- Обоснование: Используется паттерн TRUNCATE+INSERT (полный снимок). +-- Для узких таблиц Row-store производительнее Column-store при чтении всей строки. +CREATE TABLE IF NOT EXISTS ods.airports ( + airport_code TEXT NOT NULL, + airport_name TEXT NOT NULL, + city TEXT NOT NULL, + country TEXT NOT NULL, + coordinates TEXT, + timezone TEXT NOT NULL, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +DISTRIBUTED BY (airport_code); + +COMMENT ON TABLE ods.airports IS 'Справочник аэропортов (ODS).'; diff --git a/sql/ods/airports_dq.sql b/sql/ods/airports_dq.sql new file mode 100644 index 0000000..b429e3e --- /dev/null +++ b/sql/ods/airports_dq.sql @@ -0,0 +1,105 @@ +-- DQ для ODS airports. + +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.airports + WHERE _load_id = v_batch_id; + + IF v_stg_batch_count = 0 THEN + RAISE EXCEPTION + 'DQ FAILED: _load_id=% для stg.airports пустой. Проверьте загрузку STG и PXF.', + v_batch_id; + END IF; + + -- В ODS не должно быть дублей по бизнес-ключу. + SELECT COUNT(*) - COUNT(DISTINCT airport_code) + INTO v_dup_count + FROM ods.airports; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.airports найдены дубликаты airport_code: %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT airport_code + FROM stg.airports + WHERE _load_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.airports AS o + WHERE o.airport_code = s.airport_code + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.airports отсутствуют ключи из stg.airports (_load_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- В ODS не должно быть лишних ключей, которых нет в snapshot текущего батча. + SELECT COUNT(*) + INTO v_extra_keys_count + FROM ods.airports AS o + WHERE NOT EXISTS ( + SELECT 1 + FROM ( + SELECT DISTINCT airport_code + FROM stg.airports + WHERE _load_id = v_batch_id + ) AS s + WHERE s.airport_code = o.airport_code + ); + + IF v_extra_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.airports найдены лишние ключи вне stg _load_id=%: %', + v_batch_id, + v_extra_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.airports + WHERE airport_code IS NULL + OR airport_code = '' + OR airport_name IS NULL + OR airport_name = '' + OR city IS NULL + OR city = '' + OR country IS NULL + OR country = '' + OR timezone IS NULL + OR timezone = '' + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.airports найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.airports ок (_load_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/airports_load.sql b/sql/ods/airports_load.sql new file mode 100644 index 0000000..41470c7 --- /dev/null +++ b/sql/ods/airports_load.sql @@ -0,0 +1,45 @@ +-- Загрузка ODS по airports: Полная перезагрузка (TRUNCATE + INSERT). +-- Почему: для справочников-снимков в Greenplum на AO-таблицах +-- эффективнее перетереть данные целиком, чем делать медленный UPDATE. + +TRUNCATE TABLE ods.airports; + +INSERT INTO ods.airports ( + airport_code, + airport_name, + city, + country, + coordinates, + timezone, + _load_id, + _load_ts +) +WITH src AS ( + -- Выбираем последний снимок из STG для текущего батча + SELECT + s.airport_code, + s.airport_name::json->>'ru' AS airport_name, + s.city::json->>'ru' AS city, + s.country::json->>'ru' AS country, + s.coordinates, + s.timezone, + ROW_NUMBER() OVER ( + PARTITION BY s.airport_code + ORDER BY s._load_ts DESC, s.event_ts DESC NULLS LAST + ) AS rn + FROM stg.airports AS s + WHERE s._load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +SELECT + s.airport_code, + s.airport_name, + s.city, + s.country, + s.coordinates, + s.timezone, + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + now() +FROM src AS s +WHERE s.rn = 1; + +ANALYZE ods.airports; diff --git a/sql/ods/boarding_passes_ddl.sql b/sql/ods/boarding_passes_ddl.sql new file mode 100644 index 0000000..a159432 --- /dev/null +++ b/sql/ods/boarding_passes_ddl.sql @@ -0,0 +1,21 @@ +-- DDL для ODS-слоя по таблице boarding_passes (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +-- Тип таблицы: Heap (стандартная). +-- Обоснование: Необходим row-level UPDATE для реализации UPSERT/SCD1. +-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы. +CREATE TABLE IF NOT EXISTS ods.boarding_passes ( + ticket_no TEXT NOT NULL, + flight_id INTEGER NOT NULL, + seat_no TEXT NOT NULL, + boarding_no INTEGER, + boarding_time TIMESTAMP WITH TIME ZONE, + event_ts TIMESTAMP, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) +DISTRIBUTED BY (ticket_no); + +COMMENT ON TABLE ods.boarding_passes IS 'Посадочные талоны (ODS).'; diff --git a/sql/ods/boarding_passes_dq.sql b/sql/ods/boarding_passes_dq.sql new file mode 100644 index 0000000..5d30113 --- /dev/null +++ b/sql/ods/boarding_passes_dq.sql @@ -0,0 +1,96 @@ +-- DQ для ODS boarding_passes. + +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_null_count BIGINT; + v_orphan_segment_count BIGINT; +BEGIN + -- Для этой таблицы пустой батч допустим. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.boarding_passes + WHERE _load_id = v_batch_id; + + -- В ODS не должно быть дублей по составному бизнес-ключу. + SELECT COUNT(*) + INTO v_dup_count + FROM ( + SELECT ticket_no, flight_id + FROM ods.boarding_passes + GROUP BY ticket_no, flight_id + HAVING COUNT(*) > 1 + ) AS d; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.boarding_passes найдены дубликаты (ticket_no, flight_id): %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT ticket_no, NULLIF(flight_id, '')::INTEGER AS flight_id + FROM stg.boarding_passes + WHERE _load_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.boarding_passes AS o + WHERE o.ticket_no = s.ticket_no + AND o.flight_id = s.flight_id + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.boarding_passes отсутствуют ключи из stg.boarding_passes (_load_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.boarding_passes + WHERE ticket_no IS NULL + OR ticket_no = '' + OR flight_id IS NULL + OR seat_no IS NULL + OR seat_no = '' + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.boarding_passes найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + -- Ссылочная целостность: boarding_passes (ticket_no, flight_id) -> segments. + SELECT COUNT(*) + INTO v_orphan_segment_count + FROM ods.boarding_passes AS bp + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.segments AS s + WHERE s.ticket_no = bp.ticket_no + AND s.flight_id = bp.flight_id + ); + + IF v_orphan_segment_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.boarding_passes найдены строки без соответствующего ods.segments: %', + v_orphan_segment_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.boarding_passes ок (_load_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/boarding_passes_load.sql b/sql/ods/boarding_passes_load.sql new file mode 100644 index 0000000..65fe336 --- /dev/null +++ b/sql/ods/boarding_passes_load.sql @@ -0,0 +1,72 @@ +-- Загрузка ODS по boarding_passes: SCD1 (UPDATE изменившихся + INSERT новых). +-- Используем паттерн Temporary Table для предотвращения гонки HWM между UPDATE и INSERT. + +-- 1. Сбор дельты во временную таблицу. +CREATE TEMP TABLE tmp_boarding_passes_delta ON COMMIT DROP AS +WITH src AS ( + SELECT + s.ticket_no, + NULLIF(s.flight_id, '')::INTEGER AS flight_id, + s.seat_no, + NULLIF(s.boarding_no, '')::INTEGER AS boarding_no, + NULLIF(s.boarding_time, '')::TIMESTAMP WITH TIME ZONE AS boarding_time, + s.event_ts, + s._load_id, + s._load_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.ticket_no, s.flight_id + ORDER BY s.event_ts DESC NULLS LAST, s._load_ts DESC + ) AS rn + FROM stg.boarding_passes AS s + -- Используем HWM (High Water Mark) по техническому времени STG + WHERE s._load_ts > (SELECT COALESCE(MAX(_load_ts), '1900-01-01 00:00:00'::TIMESTAMP) FROM ods.boarding_passes) +) +SELECT * FROM src WHERE rn = 1; + +-- 2. UPDATE существующих строк. +UPDATE ods.boarding_passes AS o +SET seat_no = s.seat_no, + boarding_no = s.boarding_no, + boarding_time = s.boarding_time, + event_ts = s.event_ts, + _load_id = s._load_id, -- Сохраняем оригинальный lineage из STG + _load_ts = s._load_ts -- Фиксируем время STG как водяной знак для ODS +FROM tmp_boarding_passes_delta AS s +WHERE o.ticket_no = s.ticket_no + AND o.flight_id = s.flight_id + AND ( + o.seat_no IS DISTINCT FROM s.seat_no + OR o.boarding_no IS DISTINCT FROM s.boarding_no + OR o.boarding_time IS DISTINCT FROM s.boarding_time + OR o.event_ts IS DISTINCT FROM s.event_ts + ); + +-- 3. INSERT новых строк. +INSERT INTO ods.boarding_passes ( + ticket_no, + flight_id, + seat_no, + boarding_no, + boarding_time, + event_ts, + _load_id, + _load_ts +) +SELECT + s.ticket_no, + s.flight_id, + s.seat_no, + s.boarding_no, + s.boarding_time, + s.event_ts, + s._load_id, + s._load_ts +FROM tmp_boarding_passes_delta AS s +WHERE NOT EXISTS ( + SELECT 1 + FROM ods.boarding_passes AS o + WHERE o.ticket_no = s.ticket_no + AND o.flight_id = s.flight_id +); + +ANALYZE ods.boarding_passes; diff --git a/sql/ods/bookings_ddl.sql b/sql/ods/bookings_ddl.sql new file mode 100644 index 0000000..e8f6dcb --- /dev/null +++ b/sql/ods/bookings_ddl.sql @@ -0,0 +1,19 @@ +-- DDL для ODS-слоя по таблице bookings (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +-- Тип таблицы: Heap (стандартная). +-- Обоснование: Необходим row-level UPDATE для реализации UPSERT/SCD1. +-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы. +CREATE TABLE IF NOT EXISTS ods.bookings ( + book_ref TEXT NOT NULL, + book_date TIMESTAMP WITH TIME ZONE NOT NULL, + total_amount NUMERIC(10,2) NOT NULL, + event_ts TIMESTAMP, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) +DISTRIBUTED BY (book_ref); + +COMMENT ON TABLE ods.bookings IS 'Бронирования (ODS).'; diff --git a/sql/ods/bookings_dq.sql b/sql/ods/bookings_dq.sql new file mode 100644 index 0000000..3425cb0 --- /dev/null +++ b/sql/ods/bookings_dq.sql @@ -0,0 +1,71 @@ +-- DQ для ODS bookings. + +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_null_count BIGINT; +BEGIN + -- Для инкрементальных таблиц пустой батч допустим. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.bookings + WHERE _load_id = v_batch_id; + + -- В ODS не должно быть дублей по бизнес-ключу. + SELECT COUNT(*) - COUNT(DISTINCT book_ref) + INTO v_dup_count + FROM ods.bookings; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.bookings найдены дубликаты book_ref: %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT book_ref + FROM stg.bookings + WHERE _load_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.bookings AS o + WHERE o.book_ref = s.book_ref + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.bookings отсутствуют ключи из stg.bookings (_load_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.bookings + WHERE book_ref IS NULL + OR book_ref = '' + OR book_date IS NULL + OR total_amount 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: в ods.bookings найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.bookings ок (_load_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/bookings_load.sql b/sql/ods/bookings_load.sql new file mode 100644 index 0000000..74f5a01 --- /dev/null +++ b/sql/ods/bookings_load.sql @@ -0,0 +1,62 @@ +-- Загрузка ODS по bookings: SCD1 (UPDATE изменившихся + INSERT новых). +-- Используем паттерн Temporary Table для предотвращения гонки HWM между UPDATE и INSERT. + +-- 1. Сбор дельты во временную таблицу. +CREATE TEMP TABLE tmp_bookings_delta ON COMMIT DROP AS +WITH src AS ( + SELECT + s.book_ref, + NULLIF(s.book_date, '')::TIMESTAMP WITH TIME ZONE AS book_date, + NULLIF(s.total_amount, '')::NUMERIC(10,2) AS total_amount, + s.event_ts, + s._load_id, + s._load_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.book_ref + ORDER BY s.event_ts DESC NULLS LAST, s._load_ts DESC + ) AS rn + FROM stg.bookings AS s + -- Используем HWM (High Water Mark) по техническому времени STG + WHERE s._load_ts > (SELECT COALESCE(MAX(_load_ts), '1900-01-01 00:00:00'::TIMESTAMP) FROM ods.bookings) +) +SELECT * FROM src WHERE rn = 1; + +-- 2. UPDATE существующих строк. +UPDATE ods.bookings AS o +SET book_date = s.book_date, + total_amount = s.total_amount, + event_ts = s.event_ts, + _load_id = s._load_id, -- Сохраняем оригинальный lineage из STG + _load_ts = s._load_ts -- Фиксируем время STG как водяной знак для ODS +FROM tmp_bookings_delta AS s +WHERE o.book_ref = s.book_ref + AND ( + o.book_date IS DISTINCT FROM s.book_date + OR o.total_amount IS DISTINCT FROM s.total_amount + OR o.event_ts IS DISTINCT FROM s.event_ts + ); + +-- 3. INSERT новых строк. +INSERT INTO ods.bookings ( + book_ref, + book_date, + total_amount, + event_ts, + _load_id, + _load_ts +) +SELECT + s.book_ref, + s.book_date, + s.total_amount, + s.event_ts, + s._load_id, + s._load_ts +FROM tmp_bookings_delta AS s +WHERE NOT EXISTS ( + SELECT 1 + FROM ods.bookings AS o + WHERE o.book_ref = s.book_ref +); + +ANALYZE ods.bookings; diff --git a/sql/ods/flights_ddl.sql b/sql/ods/flights_ddl.sql new file mode 100644 index 0000000..84c6e04 --- /dev/null +++ b/sql/ods/flights_ddl.sql @@ -0,0 +1,23 @@ +-- DDL для ODS-слоя по таблице flights (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +-- Тип таблицы: Heap (стандартная). +-- Обоснование: Необходим row-level UPDATE для реализации UPSERT/SCD1. +-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы. +CREATE TABLE IF NOT EXISTS ods.flights ( + flight_id INTEGER NOT NULL, + route_no TEXT NOT NULL, + status TEXT NOT NULL, + scheduled_departure TIMESTAMP WITH TIME ZONE, + scheduled_arrival TIMESTAMP WITH TIME ZONE, + actual_departure TIMESTAMP WITH TIME ZONE, + actual_arrival TIMESTAMP WITH TIME ZONE, + event_ts TIMESTAMP, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) +DISTRIBUTED BY (flight_id); + +COMMENT ON TABLE ods.flights IS 'Рейсы (ODS).'; diff --git a/sql/ods/flights_dq.sql b/sql/ods/flights_dq.sql new file mode 100644 index 0000000..0a400df --- /dev/null +++ b/sql/ods/flights_dq.sql @@ -0,0 +1,89 @@ +-- DQ для ODS flights. + +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_null_count BIGINT; + v_orphan_route_count BIGINT; +BEGIN + -- Для инкрементальных таблиц пустой батч допустим. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.flights + WHERE _load_id = v_batch_id; + + -- В ODS не должно быть дублей по бизнес-ключу. + SELECT COUNT(*) - COUNT(DISTINCT flight_id) + INTO v_dup_count + FROM ods.flights; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.flights найдены дубликаты flight_id: %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT NULLIF(flight_id, '')::INTEGER AS flight_id + FROM stg.flights + WHERE _load_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.flights AS o + WHERE o.flight_id = s.flight_id + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.flights отсутствуют ключи из stg.flights (_load_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.flights + WHERE flight_id IS NULL + OR route_no IS NULL + OR route_no = '' + OR status IS NULL + OR status = '' + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.flights найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + -- Ссылочная целостность: flights.route_no -> routes.route_no (упрощённо, без validity). + SELECT COUNT(*) + INTO v_orphan_route_count + FROM ods.flights AS f + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.routes AS r + WHERE r.route_no = f.route_no + ); + + IF v_orphan_route_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.flights найдены строки без соответствующего routes.route_no: %', + v_orphan_route_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.flights ок (_load_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/flights_load.sql b/sql/ods/flights_load.sql new file mode 100644 index 0000000..f2bb5a5 --- /dev/null +++ b/sql/ods/flights_load.sql @@ -0,0 +1,140 @@ +-- Загрузка ODS по flights: SCD1 (UPDATE изменившихся + INSERT новых). +-- Используем паттерн Temporary Table для предотвращения гонки HWM между UPDATE и INSERT. + +-- 1. Сбор дельты во временную таблицу. +CREATE TEMP TABLE tmp_flights_delta ON COMMIT DROP AS +WITH segment_flights AS ( + -- В segments текущего batch могут быть flight_id не только из stg.flights этого batch. + -- Поэтому заранее собираем список flight_id из segments текущего batch (по HWM). + SELECT DISTINCT + NULLIF(s.flight_id, '')::INTEGER AS flight_id + FROM stg.segments AS s + WHERE s._load_ts > (SELECT COALESCE(MAX(_load_ts), '1900-01-01 00:00:00'::TIMESTAMP) FROM ods.segments) + AND s.flight_id IS NOT NULL + AND s.flight_id <> '' +), +stg_flights_typed AS ( + SELECT + NULLIF(s.flight_id, '')::INTEGER AS flight_id, + s.route_no, + s.status, + NULLIF(s.scheduled_departure, '')::TIMESTAMP WITH TIME ZONE AS scheduled_departure, + NULLIF(s.scheduled_arrival, '')::TIMESTAMP WITH TIME ZONE AS scheduled_arrival, + NULLIF(s.actual_departure, '')::TIMESTAMP WITH TIME ZONE AS actual_departure, + NULLIF(s.actual_arrival, '')::TIMESTAMP WITH TIME ZONE AS actual_arrival, + s.event_ts, + s._load_ts, + s._load_id + FROM stg.flights AS s + WHERE s.flight_id IS NOT NULL + AND s.flight_id <> '' +), +src_union AS ( + -- 1) Рейсы из текущего batch (по HWM). + SELECT + f.flight_id, + f.route_no, + f.status, + f.scheduled_departure, + f.scheduled_arrival, + f.actual_departure, + f.actual_arrival, + f.event_ts, + f._load_ts, + f._load_id + FROM stg_flights_typed AS f + WHERE f._load_ts > (SELECT COALESCE(MAX(_load_ts), '1900-01-01 00:00:00'::TIMESTAMP) FROM ods.flights) + + UNION ALL + + -- 2) Добираем из истории STG рейсы, на которые ссылаются segments текущего batch. + SELECT + f.flight_id, + f.route_no, + f.status, + f.scheduled_departure, + f.scheduled_arrival, + f.actual_departure, + f.actual_arrival, + f.event_ts, + f._load_ts, + f._load_id + FROM stg_flights_typed AS f + JOIN segment_flights AS sf + ON sf.flight_id = f.flight_id +), +src AS ( + SELECT + u.flight_id, + u.route_no, + u.status, + u.scheduled_departure, + u.scheduled_arrival, + u.actual_departure, + u.actual_arrival, + u.event_ts, + u._load_id, + u._load_ts, + ROW_NUMBER() OVER ( + PARTITION BY u.flight_id + ORDER BY u.event_ts DESC NULLS LAST, u._load_ts DESC + ) AS rn + FROM src_union AS u +) +SELECT * FROM src WHERE rn = 1; + +-- 2. UPDATE существующих строк. +UPDATE ods.flights AS o +SET route_no = s.route_no, + status = s.status, + scheduled_departure = s.scheduled_departure, + scheduled_arrival = s.scheduled_arrival, + actual_departure = s.actual_departure, + actual_arrival = s.actual_arrival, + event_ts = s.event_ts, + _load_id = s._load_id, -- Сохраняем оригинальный lineage из STG + _load_ts = s._load_ts -- Фиксируем время STG как водяной знак для ODS +FROM tmp_flights_delta AS s +WHERE o.flight_id = s.flight_id + AND ( + o.route_no IS DISTINCT FROM s.route_no + OR o.status IS DISTINCT FROM s.status + OR o.scheduled_departure IS DISTINCT FROM s.scheduled_departure + OR o.scheduled_arrival IS DISTINCT FROM s.scheduled_arrival + OR o.actual_departure IS DISTINCT FROM s.actual_departure + OR o.actual_arrival IS DISTINCT FROM s.actual_arrival + OR o.event_ts IS DISTINCT FROM s.event_ts + ); + +-- 3. INSERT новых строк. +INSERT INTO ods.flights ( + flight_id, + route_no, + status, + scheduled_departure, + scheduled_arrival, + actual_departure, + actual_arrival, + event_ts, + _load_id, + _load_ts +) +SELECT + s.flight_id, + s.route_no, + s.status, + s.scheduled_departure, + s.scheduled_arrival, + s.actual_departure, + s.actual_arrival, + s.event_ts, + s._load_id, + s._load_ts +FROM tmp_flights_delta AS s +WHERE NOT EXISTS ( + SELECT 1 + FROM ods.flights AS o + WHERE o.flight_id = s.flight_id +); + +ANALYZE ods.flights; diff --git a/sql/ods/routes_ddl.sql b/sql/ods/routes_ddl.sql new file mode 100644 index 0000000..ac757c9 --- /dev/null +++ b/sql/ods/routes_ddl.sql @@ -0,0 +1,23 @@ +-- DDL для ODS-слоя по таблице routes (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +-- Тип таблицы: Append-Only Row-oriented (zstd:1). +-- Обоснование: Используется паттерн TRUNCATE+INSERT (полный снимок). +-- Для узких таблиц Row-store производительнее Column-store при чтении всей строки. +CREATE TABLE IF NOT EXISTS ods.routes ( + route_no TEXT NOT NULL, + validity TEXT NOT NULL, + departure_airport TEXT NOT NULL, + arrival_airport TEXT NOT NULL, + airplane_code TEXT NOT NULL, + days_of_week INTEGER[], + departure_time TIME NOT NULL, + duration INTERVAL NOT NULL, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +DISTRIBUTED BY (route_no, validity); + +COMMENT ON TABLE ods.routes IS 'Справочник маршрутов (ODS).'; diff --git a/sql/ods/routes_dq.sql b/sql/ods/routes_dq.sql new file mode 100644 index 0000000..4dcf325 --- /dev/null +++ b/sql/ods/routes_dq.sql @@ -0,0 +1,163 @@ +-- DQ для ODS routes. + +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_departure_count BIGINT; + v_orphan_arrival_count BIGINT; + v_orphan_airplane_count BIGINT; +BEGIN + -- Для snapshot-справочников пустой батч — ошибка. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.routes + WHERE _load_id = v_batch_id; + + IF v_stg_batch_count = 0 THEN + RAISE EXCEPTION + 'DQ FAILED: _load_id=% для stg.routes пустой. Проверьте загрузку STG и PXF.', + v_batch_id; + END IF; + + -- В ODS не должно быть дублей по составному бизнес-ключу. + SELECT COUNT(*) + INTO v_dup_count + FROM ( + SELECT route_no, validity + FROM ods.routes + GROUP BY route_no, validity + HAVING COUNT(*) > 1 + ) AS d; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.routes найдены дубликаты (route_no, validity): %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT route_no, validity + FROM stg.routes + WHERE _load_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.routes AS o + WHERE o.route_no = s.route_no + AND o.validity = s.validity + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.routes отсутствуют ключи из stg.routes (_load_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- В ODS не должно быть лишних ключей, которых нет в snapshot текущего батча. + SELECT COUNT(*) + INTO v_extra_keys_count + FROM ods.routes AS o + WHERE NOT EXISTS ( + SELECT 1 + FROM ( + SELECT DISTINCT route_no, validity + FROM stg.routes + WHERE _load_id = v_batch_id + ) AS s + WHERE s.route_no = o.route_no + AND s.validity = o.validity + ); + + IF v_extra_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.routes найдены лишние ключи вне stg _load_id=%: %', + v_batch_id, + v_extra_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.routes + WHERE route_no IS NULL + OR route_no = '' + OR validity IS NULL + OR validity = '' + 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 _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.routes найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + -- Ссылочная целостность: departure_airport должен существовать в ods.airports. + SELECT COUNT(*) + INTO v_orphan_departure_count + FROM ods.routes AS r + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.airports AS a + WHERE a.airport_code = r.departure_airport + ); + + IF v_orphan_departure_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.routes найдены строки с невалидным departure_airport: %', + v_orphan_departure_count; + END IF; + + -- Ссылочная целостность: arrival_airport должен существовать в ods.airports. + SELECT COUNT(*) + INTO v_orphan_arrival_count + FROM ods.routes AS r + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.airports AS a + WHERE a.airport_code = r.arrival_airport + ); + + IF v_orphan_arrival_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.routes найдены строки с невалидным arrival_airport: %', + 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; + + RAISE NOTICE + 'DQ PASSED: ods.routes ок (_load_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/routes_load.sql b/sql/ods/routes_load.sql new file mode 100644 index 0000000..81b3af5 --- /dev/null +++ b/sql/ods/routes_load.sql @@ -0,0 +1,51 @@ +-- Загрузка ODS по routes: Полная перезагрузка (TRUNCATE + INSERT). +-- Почему: для справочников-снимков в Greenplum на AO-таблицах +-- эффективнее перетереть данные целиком, чем делать медленный UPDATE. + +TRUNCATE TABLE ods.routes; + +INSERT INTO ods.routes ( + route_no, + validity, + departure_airport, + arrival_airport, + airplane_code, + days_of_week, + departure_time, + duration, + _load_id, + _load_ts +) +WITH src AS ( + -- Выбираем последний снимок из STG для текущего батча + SELECT + s.route_no, + s.validity, + s.departure_airport, + s.arrival_airport, + s.airplane_code, + s.days_of_week::INTEGER[] AS days_of_week, + NULLIF(s.scheduled_time, '')::TIME AS departure_time, + NULLIF(s.duration, '')::INTERVAL AS duration, + ROW_NUMBER() OVER ( + PARTITION BY s.route_no, s.validity + ORDER BY s._load_ts DESC, s.event_ts DESC NULLS LAST + ) AS rn + FROM stg.routes AS s + WHERE s._load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +SELECT + s.route_no, + s.validity, + s.departure_airport, + s.arrival_airport, + s.airplane_code, + s.days_of_week::INTEGER[], + s.departure_time, + s.duration, + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + now() +FROM src AS s +WHERE s.rn = 1; + +ANALYZE ods.routes; diff --git a/sql/ods/seats_ddl.sql b/sql/ods/seats_ddl.sql new file mode 100644 index 0000000..484de68 --- /dev/null +++ b/sql/ods/seats_ddl.sql @@ -0,0 +1,18 @@ +-- DDL для ODS-слоя по таблице seats (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +-- Тип таблицы: Append-Only Row-oriented (zstd:1). +-- Обоснование: Используется паттерн TRUNCATE+INSERT (полный снимок). +-- Для узких таблиц Row-store производительнее Column-store при чтении всей строки. +CREATE TABLE IF NOT EXISTS ods.seats ( + airplane_code TEXT NOT NULL, + seat_no TEXT NOT NULL, + fare_conditions TEXT NOT NULL, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +DISTRIBUTED BY (airplane_code); + +COMMENT ON TABLE ods.seats IS 'Справочник мест в самолетах (ODS).'; diff --git a/sql/ods/seats_dq.sql b/sql/ods/seats_dq.sql new file mode 100644 index 0000000..e8c4111 --- /dev/null +++ b/sql/ods/seats_dq.sql @@ -0,0 +1,125 @@ +-- 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 $$; diff --git a/sql/ods/seats_load.sql b/sql/ods/seats_load.sql new file mode 100644 index 0000000..4d3d091 --- /dev/null +++ b/sql/ods/seats_load.sql @@ -0,0 +1,36 @@ +-- Загрузка 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; diff --git a/sql/ods/segments_ddl.sql b/sql/ods/segments_ddl.sql new file mode 100644 index 0000000..3a2521a --- /dev/null +++ b/sql/ods/segments_ddl.sql @@ -0,0 +1,21 @@ +-- DDL для ODS-слоя по таблице segments (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +-- Тип таблицы: Heap (стандартная). +-- Обоснование: Необходим row-level UPDATE для реализации UPSERT/SCD1. +-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы. +CREATE TABLE IF NOT EXISTS ods.segments ( + ticket_no TEXT NOT NULL, + flight_id INTEGER NOT NULL, + fare_conditions TEXT NOT NULL, + amount NUMERIC(10,2) NOT NULL, + event_ts TIMESTAMP, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) +DISTRIBUTED BY (ticket_no); + +COMMENT ON TABLE ods.segments IS 'Сегменты перелета (ODS).'; + diff --git a/sql/ods/segments_dq.sql b/sql/ods/segments_dq.sql new file mode 100644 index 0000000..a2e8403 --- /dev/null +++ b/sql/ods/segments_dq.sql @@ -0,0 +1,112 @@ +-- DQ для ODS segments. + +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_null_count BIGINT; + v_orphan_ticket_count BIGINT; + v_orphan_flight_count BIGINT; +BEGIN + -- Для инкрементальных таблиц пустой батч допустим. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.segments + WHERE _load_id = v_batch_id; + + -- В ODS не должно быть дублей по составному бизнес-ключу. + SELECT COUNT(*) + INTO v_dup_count + FROM ( + SELECT ticket_no, flight_id + FROM ods.segments + GROUP BY ticket_no, flight_id + HAVING COUNT(*) > 1 + ) AS d; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.segments найдены дубликаты (ticket_no, flight_id): %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT ticket_no, NULLIF(flight_id, '')::INTEGER AS flight_id + FROM stg.segments + WHERE _load_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.segments AS o + WHERE o.ticket_no = s.ticket_no + AND o.flight_id = s.flight_id + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.segments отсутствуют ключи из stg.segments (_load_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.segments + WHERE ticket_no IS NULL + OR ticket_no = '' + OR flight_id IS NULL + 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.segments найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + -- Ссылочная целостность: segments.ticket_no -> tickets.ticket_no. + SELECT COUNT(*) + INTO v_orphan_ticket_count + FROM ods.segments AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.tickets AS t + WHERE t.ticket_no = s.ticket_no + ); + + IF v_orphan_ticket_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.segments найдены строки без соответствующего tickets.ticket_no: %', + v_orphan_ticket_count; + END IF; + + -- Ссылочная целостность: segments.flight_id -> flights.flight_id. + SELECT COUNT(*) + INTO v_orphan_flight_count + FROM ods.segments AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.flights AS f + WHERE f.flight_id = s.flight_id + ); + + IF v_orphan_flight_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.segments найдены строки без соответствующего flights.flight_id: %', + v_orphan_flight_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.segments ок (_load_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/segments_load.sql b/sql/ods/segments_load.sql new file mode 100644 index 0000000..661cc8d --- /dev/null +++ b/sql/ods/segments_load.sql @@ -0,0 +1,67 @@ +-- Загрузка ODS по segments: SCD1 (UPDATE изменившихся + INSERT новых). +-- Используем паттерн Temporary Table для предотвращения гонки HWM между UPDATE и INSERT. + +-- 1. Сбор дельты во временную таблицу. +CREATE TEMP TABLE tmp_segments_delta ON COMMIT DROP AS +WITH src AS ( + SELECT + s.ticket_no, + NULLIF(s.flight_id, '')::INTEGER AS flight_id, + s.fare_conditions, + NULLIF(s.price, '')::NUMERIC(10,2) AS amount, + s.event_ts, + s._load_id, + s._load_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.ticket_no, s.flight_id + ORDER BY s.event_ts DESC NULLS LAST, s._load_ts DESC + ) AS rn + FROM stg.segments AS s + -- Используем HWM (High Water Mark) по техническому времени STG + WHERE s._load_ts > (SELECT COALESCE(MAX(_load_ts), '1900-01-01 00:00:00'::TIMESTAMP) FROM ods.segments) +) +SELECT * FROM src WHERE rn = 1; + +-- 2. UPDATE существующих строк. +UPDATE ods.segments AS o +SET fare_conditions = s.fare_conditions, + amount = s.amount, + event_ts = s.event_ts, + _load_id = s._load_id, -- Сохраняем оригинальный lineage из STG + _load_ts = s._load_ts -- Фиксируем время STG как водяной знак для ODS +FROM tmp_segments_delta AS s +WHERE o.ticket_no = s.ticket_no + AND o.flight_id = s.flight_id + AND ( + o.fare_conditions IS DISTINCT FROM s.fare_conditions + OR o.amount IS DISTINCT FROM s.amount + OR o.event_ts IS DISTINCT FROM s.event_ts + ); + +-- 3. INSERT новых строк. +INSERT INTO ods.segments ( + ticket_no, + flight_id, + fare_conditions, + amount, + event_ts, + _load_id, + _load_ts +) +SELECT + s.ticket_no, + s.flight_id, + s.fare_conditions, + s.amount, + s.event_ts, + s._load_id, + s._load_ts +FROM tmp_segments_delta AS s +WHERE NOT EXISTS ( + SELECT 1 + FROM ods.segments AS o + WHERE o.ticket_no = s.ticket_no + AND o.flight_id = s.flight_id +); + +ANALYZE ods.segments; diff --git a/sql/ods/tickets_ddl.sql b/sql/ods/tickets_ddl.sql new file mode 100644 index 0000000..25e8b2c --- /dev/null +++ b/sql/ods/tickets_ddl.sql @@ -0,0 +1,21 @@ +-- DDL для ODS-слоя по таблице tickets (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +-- Тип таблицы: Heap (стандартная). +-- Обоснование: Необходим row-level UPDATE для реализации UPSERT/SCD1. +-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы. +CREATE TABLE IF NOT EXISTS ods.tickets ( + ticket_no TEXT NOT NULL, + book_ref TEXT NOT NULL, + passenger_id TEXT NOT NULL, + passenger_name TEXT NOT NULL, + is_outbound BOOLEAN, + event_ts TIMESTAMP, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +WITH (appendonly=false) +DISTRIBUTED BY (ticket_no); + +COMMENT ON TABLE ods.tickets IS 'Билеты (ODS).'; diff --git a/sql/ods/tickets_dq.sql b/sql/ods/tickets_dq.sql new file mode 100644 index 0000000..638784b --- /dev/null +++ b/sql/ods/tickets_dq.sql @@ -0,0 +1,92 @@ +-- DQ для ODS tickets. + +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_null_count BIGINT; + v_orphan_booking_count BIGINT; +BEGIN + -- Для инкрементальных таблиц пустой батч допустим. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.tickets + WHERE _load_id = v_batch_id; + + -- В ODS не должно быть дублей по бизнес-ключу. + SELECT COUNT(*) - COUNT(DISTINCT ticket_no) + INTO v_dup_count + FROM ods.tickets; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.tickets найдены дубликаты ticket_no: %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT ticket_no + FROM stg.tickets + WHERE _load_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.tickets AS o + WHERE o.ticket_no = s.ticket_no + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.tickets отсутствуют ключи из stg.tickets (_load_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.tickets + WHERE ticket_no IS NULL + OR ticket_no = '' + OR book_ref IS NULL + OR book_ref = '' + OR passenger_id IS NULL + OR passenger_id = '' + OR passenger_name IS NULL + OR passenger_name = '' + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.tickets найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + -- Ссылочная целостность: tickets.book_ref -> bookings.book_ref. + SELECT COUNT(*) + INTO v_orphan_booking_count + FROM ods.tickets AS t + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.bookings AS b + WHERE b.book_ref = t.book_ref + ); + + IF v_orphan_booking_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.tickets найдены строки без соответствующего bookings.book_ref: %', + v_orphan_booking_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.tickets ок (_load_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/tickets_load.sql b/sql/ods/tickets_load.sql new file mode 100644 index 0000000..c0bf4c3 --- /dev/null +++ b/sql/ods/tickets_load.sql @@ -0,0 +1,72 @@ +-- Загрузка ODS по tickets: SCD1 (UPDATE изменившихся + INSERT новых). +-- Используем паттерн Temporary Table для предотвращения гонки HWM между UPDATE и INSERT. + +-- 1. Сбор дельты во временную таблицу. +CREATE TEMP TABLE tmp_tickets_delta ON COMMIT DROP AS +WITH src AS ( + SELECT + s.ticket_no, + s.book_ref, + s.passenger_id, + s.passenger_name, + NULLIF(s.outbound, '')::BOOLEAN AS is_outbound, + s.event_ts, + s._load_id, + s._load_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.ticket_no + ORDER BY s.event_ts DESC NULLS LAST, s._load_ts DESC + ) AS rn + FROM stg.tickets AS s + -- Используем HWM (High Water Mark) по техническому времени STG + WHERE s._load_ts > (SELECT COALESCE(MAX(_load_ts), '1900-01-01 00:00:00'::TIMESTAMP) FROM ods.tickets) +) +SELECT * FROM src WHERE rn = 1; + +-- 2. UPDATE существующих строк. +UPDATE ods.tickets AS o +SET book_ref = s.book_ref, + passenger_id = s.passenger_id, + passenger_name = s.passenger_name, + is_outbound = s.is_outbound, + event_ts = s.event_ts, + _load_id = s._load_id, -- Сохраняем оригинальный lineage из STG + _load_ts = s._load_ts -- Фиксируем время STG как водяной знак для ODS +FROM tmp_tickets_delta AS s +WHERE o.ticket_no = s.ticket_no + AND ( + o.book_ref IS DISTINCT FROM s.book_ref + OR o.passenger_id IS DISTINCT FROM s.passenger_id + OR o.passenger_name IS DISTINCT FROM s.passenger_name + OR o.is_outbound IS DISTINCT FROM s.is_outbound + OR o.event_ts IS DISTINCT FROM s.event_ts + ); + +-- 3. INSERT новых строк. +INSERT INTO ods.tickets ( + ticket_no, + book_ref, + passenger_id, + passenger_name, + is_outbound, + event_ts, + _load_id, + _load_ts +) +SELECT + s.ticket_no, + s.book_ref, + s.passenger_id, + s.passenger_name, + s.is_outbound, + s.event_ts, + s._load_id, + s._load_ts +FROM tmp_tickets_delta AS s +WHERE NOT EXISTS ( + SELECT 1 + FROM ods.tickets AS o + WHERE o.ticket_no = s.ticket_no +); + +ANALYZE ods.tickets; diff --git a/sql/src/bookings_generate_day_if_missing.sql b/sql/src/bookings_generate_day_if_missing.sql index f493cbc..4e1da9a 100644 --- a/sql/src/bookings_generate_day_if_missing.sql +++ b/sql/src/bookings_generate_day_if_missing.sql @@ -14,12 +14,11 @@ DECLARE BEGIN -- Проверяем, что демобаза установлена IF to_regclass('bookings.bookings') IS NULL THEN - RAISE EXCEPTION 'Таблица bookings.bookings не найдена. Сначала выполните make bookings-init.'; + RAISE EXCEPTION 'Таблица bookings.bookings не найдена. Сначала выполните make bookings-init или make bookings-generate.'; END IF; - -- В учебном стенде поддерживается только jobs=1, иначе генерация нестабильна. - IF v_jobs <> 1 THEN - RAISE EXCEPTION 'Поддерживается только bookings.jobs=1. Текущее значение: %. Установите BOOKINGS_JOBS=1 и выполните make bookings-init.', v_jobs; + IF v_jobs < 1 THEN + RAISE EXCEPTION 'bookings.jobs должен быть >= 1. Текущее значение: %.', v_jobs; END IF; -- Ищем последнюю сгенерированную дату @@ -39,16 +38,31 @@ BEGIN CALL generate(v_start_date, v_end_date, v_jobs); ELSE v_end_date := v_start_date + interval '1 day'; + + -- Убираем VACUUM-ивенты из очереди: генератор demodb кладёт + -- VACUUM ANALYZE всей БД каждую неделю модельного времени. + -- На 500k+ строках это занимает минуты и бессмысленно для +1 дня. + DELETE FROM gen.events WHERE type = 'VACUUM'; + CALL continue(v_end_date, v_jobs); END IF; + -- continue() делает TRUNCATE gen.stat_jobs → AccessExclusiveLock. + -- Без COMMIT воркеры не могут INSERT INTO gen.stat_jobs → deadlock. + COMMIT; + RAISE NOTICE 'Сгенерированы данные в bookings.bookings за интервал [% - %).', date_trunc('day', v_start_date), date_trunc('day', v_end_date); - -- Ждём завершения фоновых джобов генератора, чтобы данные успели записаться - WHILE busy() LOOP - PERFORM pg_sleep(1); - END LOOP; + -- Ждём завершения каждого воркера через dblink_is_busy() (см. комментарий + -- в bookings/generate_next_day.sql — busy() через pg_stat_activity ненадёжен). + IF v_jobs > 1 THEN + FOR i IN 1 .. v_jobs LOOP + WHILE dblink_is_busy('job' || i) = 1 LOOP + PERFORM pg_sleep(1); + END LOOP; + END LOOP; + END IF; PERFORM dblink_disconnect(unnest(dblink_get_connections())); END $$; diff --git a/sql/stg/airplanes_ddl.sql b/sql/stg/airplanes_ddl.sql new file mode 100644 index 0000000..46aa28b --- /dev/null +++ b/sql/stg/airplanes_ddl.sql @@ -0,0 +1,38 @@ +-- DDL для слоя STG по таблице airplanes (справочник). +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.airplanes_data через PXF. +-- PXF не поддерживает тип JSONB - используем TEXT для всех колонок. +DROP EXTERNAL TABLE IF EXISTS stg.airplanes_ext; +CREATE EXTERNAL TABLE stg.airplanes_ext ( + airplane_code TEXT, + model TEXT, + range TEXT, + speed TEXT +) +LOCATION ('pxf://bookings.airplanes_data?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.airplanes — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.airplanes ( + airplane_code TEXT, + model TEXT, + range TEXT, + speed TEXT, + event_ts TIMESTAMP, + _load_ts TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +-- Ключ распределения: airplane_code +-- Обоснование: airplane_code — это уникальный идентификатор самолёта. +-- Использование airplane_code обеспечивает: +-- 1. Равномерное распределение данных по сегментам (airplane_code имеет высокую кардинальность) +-- 2. Коллокацию данных airplanes и seats при JOIN по airplane_code +-- 3. Оптимизацию запросов, которые фильтруют или группируют по airplane_code +-- Примечание: JOIN с таблицей routes (распределённой по route_no) может требовать motion. +DISTRIBUTED BY (airplane_code); diff --git a/sql/stg/airplanes_dq.sql b/sql/stg/airplanes_dq.sql new file mode 100644 index 0000000..e05b260 --- /dev/null +++ b/sql/stg/airplanes_dq.sql @@ -0,0 +1,67 @@ +-- Проверки качества данных для airplanes (справочник) + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; +BEGIN + -- Источник: считаем все строки во внешней таблице + SELECT COUNT(*) + INTO v_src_count + FROM stg.airplanes_ext; + + IF v_src_count = 0 THEN + RAISE EXCEPTION + 'В источнике airplanes_ext нет строк. Проверьте: bookings-db запущен, PXF работает, STG DDL применён (bookings_stg_ddl или make ddl-gp).'; + END IF; + + -- Считаем строки, реально вставленные в stg.airplanes в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.airplanes + WHERE _load_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты airplane_code + SELECT COUNT(*) - COUNT(DISTINCT airplane_code) + INTO v_dup_count + FROM stg.airplanes AS a + WHERE a._load_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты airplane_code (_load_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей: airplane_code, model + SELECT COUNT(*) + INTO v_null_count + FROM stg.airplanes AS a + WHERE a._load_id = v_batch_id + AND (a.airplane_code IS NULL OR a.airplane_code = '' + OR a.model IS NULL OR a.model = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (airplane_code, model) (_load_id=%): %', + v_batch_id, + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: airplanes ок (_load_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; diff --git a/sql/stg/airplanes_load.sql b/sql/stg/airplanes_load.sql new file mode 100644 index 0000000..b846b9c --- /dev/null +++ b/sql/stg/airplanes_load.sql @@ -0,0 +1,32 @@ +-- Загрузка всех строк из stg.airplanes_ext в stg.airplanes (full load). +-- Используем _load_id для отслеживания загрузки. + +INSERT INTO stg.airplanes ( + airplane_code, + model, + range, + speed, + event_ts, + _load_ts, + _load_id +) +SELECT + ext.airplane_code::text, + ext.model::text, + ext.range::text, + ext.speed::text, + now()::timestamp, + now()::timestamp, + '{{ run_id }}'::text +FROM stg.airplanes_ext AS ext +WHERE NOT EXISTS ( + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего _load_id. + SELECT 1 + FROM stg.airplanes AS a + WHERE a._load_id = '{{ run_id }}'::text + AND a.airplane_code = ext.airplane_code::text +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.airplanes; diff --git a/sql/stg/airports_ddl.sql b/sql/stg/airports_ddl.sql new file mode 100644 index 0000000..708478f --- /dev/null +++ b/sql/stg/airports_ddl.sql @@ -0,0 +1,41 @@ +-- DDL для слоя STG по таблице airports (справочник). +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.airports_data через PXF. +-- PXF не поддерживает типы JSONB, POINT - используем TEXT для всех колонок. +DROP EXTERNAL TABLE IF EXISTS stg.airports_ext; +CREATE EXTERNAL TABLE stg.airports_ext ( + airport_code TEXT, + airport_name TEXT, + city TEXT, + country TEXT, + coordinates TEXT, + timezone TEXT +) +LOCATION ('pxf://bookings.airports_data?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.airports — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.airports ( + airport_code TEXT, + airport_name TEXT, + city TEXT, + country TEXT, + coordinates TEXT, + timezone TEXT, + event_ts TIMESTAMP, + _load_ts TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +-- Ключ распределения: airport_code +-- Обоснование: airport_code — это уникальный идентификатор аэропорта. +-- Использование airport_code обеспечивает: +-- 1. Равномерное распределение данных по сегментам (airport_code имеет высокую кардинальность) +-- 2. Оптимизацию запросов, которые фильтруют или группируют по airport_code +-- Примечание: JOIN с таблицей routes (распределённой по route_no) может требовать motion. +DISTRIBUTED BY (airport_code); diff --git a/sql/stg/airports_dq.sql b/sql/stg/airports_dq.sql new file mode 100644 index 0000000..8d49bf3 --- /dev/null +++ b/sql/stg/airports_dq.sql @@ -0,0 +1,69 @@ +-- Проверки качества данных для airports (справочник) + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; +BEGIN + -- Источник: считаем все строки во внешней таблице + SELECT COUNT(*) + INTO v_src_count + FROM stg.airports_ext; + + IF v_src_count = 0 THEN + RAISE EXCEPTION + 'В источнике airports_ext нет строк. Проверьте: bookings-db запущен, PXF работает, STG DDL применён (bookings_stg_ddl или make ddl-gp).'; + END IF; + + -- Считаем строки, реально вставленные в stg.airports в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.airports + WHERE _load_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты airport_code + SELECT COUNT(*) - COUNT(DISTINCT airport_code) + INTO v_dup_count + FROM stg.airports AS a + WHERE a._load_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты airport_code (_load_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей: airport_code, airport_name, city, timezone + SELECT COUNT(*) + INTO v_null_count + FROM stg.airports AS a + WHERE a._load_id = v_batch_id + AND (a.airport_code IS NULL OR a.airport_code = '' + OR a.airport_name IS NULL OR a.airport_name = '' + OR a.city IS NULL OR a.city = '' + OR a.timezone IS NULL OR a.timezone = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (airport_code, airport_name, city, timezone) (_load_id=%): %', + v_batch_id, + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: airports ок (_load_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; diff --git a/sql/stg/airports_load.sql b/sql/stg/airports_load.sql new file mode 100644 index 0000000..c42b142 --- /dev/null +++ b/sql/stg/airports_load.sql @@ -0,0 +1,36 @@ +-- Загрузка всех строк из stg.airports_ext в stg.airports (full load). +-- Используем _load_id для отслеживания загрузки. + +INSERT INTO stg.airports ( + airport_code, + airport_name, + city, + country, + coordinates, + timezone, + event_ts, + _load_ts, + _load_id +) +SELECT + ext.airport_code::text, + ext.airport_name::text, + ext.city::text, + ext.country::text, + ext.coordinates::text, + ext.timezone::text, + now()::timestamp, + now()::timestamp, + '{{ run_id }}'::text +FROM stg.airports_ext AS ext +WHERE NOT EXISTS ( + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего _load_id. + SELECT 1 + FROM stg.airports AS a + WHERE a._load_id = '{{ run_id }}'::text + AND a.airport_code = ext.airport_code::text +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.airports; diff --git a/sql/stg/boarding_passes_ddl.sql b/sql/stg/boarding_passes_ddl.sql new file mode 100644 index 0000000..29669c8 --- /dev/null +++ b/sql/stg/boarding_passes_ddl.sql @@ -0,0 +1,38 @@ +-- DDL для слоя STG по таблице boarding_passes. +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.boarding_passes через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.boarding_passes_ext; +CREATE EXTERNAL TABLE stg.boarding_passes_ext ( + ticket_no TEXT, + flight_id TEXT, + seat_no TEXT, + boarding_no INTEGER, + boarding_time TIMESTAMP +) +LOCATION ('pxf://bookings.boarding_passes?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.boarding_passes — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.boarding_passes ( + ticket_no TEXT, + flight_id TEXT, + seat_no TEXT, + boarding_no TEXT, + boarding_time TEXT, + event_ts TIMESTAMP, + _load_ts TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +-- Ключ распределения: ticket_no +-- Обоснование: ticket_no — это основной бизнес-ключ для билетов. +-- Использование ticket_no обеспечивает: +-- 1. Коллокацию данных boarding_passes и segments при JOIN по ticket_no +-- 2. Равномерное распределение данных по сегментам (ticket_no имеет высокую кардинальность) +-- Примечание: stg.tickets распределена по book_ref, поэтому JOIN boarding_passes ↔ tickets по ticket_no может требовать motion. +DISTRIBUTED BY (ticket_no); diff --git a/sql/stg/boarding_passes_dq.sql b/sql/stg/boarding_passes_dq.sql new file mode 100644 index 0000000..b9c43cf --- /dev/null +++ b/sql/stg/boarding_passes_dq.sql @@ -0,0 +1,131 @@ +-- Проверки качества данных для boarding_passes (инкрементальная загрузка). + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_prev_ts TIMESTAMP; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; + v_orphan_ticket_count BIGINT; + v_orphan_segment_count BIGINT; +BEGIN + -- Опорная метка: максимум event_ts среди предыдущих батчей + SELECT MAX(event_ts) + INTO v_prev_ts + FROM stg.boarding_passes + WHERE _load_id <> v_batch_id + OR _load_id IS NULL; + + -- Количество в источнике (boarding_passes в том же окне инкремента, что и загрузка) + SELECT COUNT(*) + INTO v_src_count + FROM stg.boarding_passes_ext AS ext + JOIN stg.tickets_ext AS t ON ext.ticket_no = t.ticket_no + JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref + WHERE b.book_date > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + + IF v_src_count = 0 THEN + -- Пустое окно инкремента допустимо: новых данных может не быть. + SELECT COUNT(*) + INTO v_stg_count + FROM stg.boarding_passes + WHERE _load_id = v_batch_id; + + IF v_stg_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: источник boarding_passes_ext за окно инкремента пустой, но в stg.boarding_passes есть строки текущего _load_id (_load_id=%): %', + v_batch_id, + v_stg_count; + END IF; + + RAISE NOTICE + 'В источнике boarding_passes_ext нет строк для окна инкремента (book_date > %). Пропускаем DQ проверки (_load_id=%).', + COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'), + v_batch_id; + RETURN; + END IF; + + -- Считаем строки, реально вставленные в stg.boarding_passes в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.boarding_passes + WHERE _load_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник (окно инкремента): %, STG (_load_id=%): %', + v_src_count, + v_batch_id, + v_stg_count; + END IF; + + -- Проверка на дубликаты (ticket_no, flight_id) в текущем батче + SELECT COUNT(*) - COUNT(DISTINCT md5(ROW(ticket_no, flight_id)::text)) + INTO v_dup_count + FROM stg.boarding_passes AS bp + WHERE bp._load_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты (ticket_no, flight_id) (_load_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей (ticket_no, flight_id) + SELECT COUNT(*) + INTO v_null_count + FROM stg.boarding_passes AS bp + WHERE bp._load_id = v_batch_id + AND (bp.ticket_no IS NULL OR bp.ticket_no = '' + OR bp.flight_id IS NULL OR bp.flight_id = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (_load_id=%): %', + v_batch_id, + v_null_count; + END IF; + + -- Проверка ссылочной целостности: все boarding_passes должны иметь соответствующие tickets + SELECT COUNT(*) + INTO v_orphan_ticket_count + FROM stg.boarding_passes AS bp + LEFT JOIN stg.tickets AS t ON bp.ticket_no = t.ticket_no + WHERE bp._load_id = v_batch_id + AND t.ticket_no IS NULL; + + IF v_orphan_ticket_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены boarding_passes без соответствующих tickets (_load_id=%): %', + v_batch_id, + v_orphan_ticket_count; + END IF; + + -- Проверка ссылочной целостности: все boarding_passes должны иметь соответствующие segments + SELECT COUNT(*) + INTO v_orphan_segment_count + FROM stg.boarding_passes AS bp + LEFT JOIN stg.segments AS s ON bp.ticket_no = s.ticket_no AND bp.flight_id = s.flight_id + WHERE bp._load_id = v_batch_id + AND s.ticket_no IS NULL; + + IF v_orphan_segment_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены boarding_passes без соответствующих segments (_load_id=%): %', + v_batch_id, + v_orphan_segment_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: boarding_passes ок (_load_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; + +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'DQ ERROR для boarding_passes (_load_id=%): %', v_batch_id, SQLERRM; + RAISE; +END $$; diff --git a/sql/stg/boarding_passes_load.sql b/sql/stg/boarding_passes_load.sql new file mode 100644 index 0000000..b46391e --- /dev/null +++ b/sql/stg/boarding_passes_load.sql @@ -0,0 +1,53 @@ +-- Загрузка инкремента из stg.boarding_passes_ext в stg.boarding_passes. +-- Инкремент определяется по дате бронирования (book_date из bookings.bookings) +-- через JOIN с таблицами tickets и bookings (аналогично segments_load.sql). +-- +-- Учебный комментарий: boarding_passes привязаны к билетам, а билеты — к бронированиям. +-- Чтобы инкремент boarding_passes совпадал с инкрементом tickets и segments, +-- используем ту же точку отсечения — book_date бронирования. Иначе при повторных +-- запусках full snapshot загрузит все boarding_passes, а tickets/segments — только +-- новые, и DQ обнаружит «сиротские» boarding_passes без соответствующих tickets. + +-- CTE для определения максимальной даты загрузки предыдущего батча +WITH max_batch_ts AS ( + SELECT COALESCE(MAX(event_ts), TIMESTAMP '1900-01-01 00:00:00') AS max_ts + FROM stg.boarding_passes + WHERE _load_id <> '{{ run_id }}'::text + OR _load_id IS NULL +) +INSERT INTO stg.boarding_passes ( + ticket_no, + flight_id, + seat_no, + boarding_no, + boarding_time, + event_ts, + _load_ts, + _load_id +) +SELECT + ext.ticket_no, + ext.flight_id, + ext.seat_no, + ext.boarding_no::text, + ext.boarding_time::text, + b.book_date::timestamp, -- временная метка из бронирования + now(), + '{{ run_id }}'::text +FROM stg.boarding_passes_ext AS ext +JOIN stg.tickets_ext AS t ON ext.ticket_no = t.ticket_no +JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref +CROSS JOIN max_batch_ts AS mb +WHERE b.book_date > mb.max_ts +AND NOT EXISTS ( + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки. + -- Считаем ключом строки (ticket_no, flight_id). + SELECT 1 + FROM stg.boarding_passes AS bp + WHERE bp._load_id = '{{ run_id }}'::text + AND bp.ticket_no = ext.ticket_no + AND bp.flight_id = ext.flight_id +); + +-- Обновляем статистику для оптимизатора Greenplum +ANALYZE stg.boarding_passes; diff --git a/sql/stg/bookings_ddl.sql b/sql/stg/bookings_ddl.sql index 5d4b8ba..13b860f 100644 --- a/sql/stg/bookings_ddl.sql +++ b/sql/stg/bookings_ddl.sql @@ -20,10 +20,15 @@ CREATE TABLE IF NOT EXISTS stg.bookings ( book_ref TEXT, book_date TEXT, total_amount TEXT, - src_created_at_ts TIMESTAMP, - load_dttm TIMESTAMP NOT NULL DEFAULT now(), - batch_id TEXT NOT NULL + event_ts TIMESTAMP, + _load_ts TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL ) -WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +-- Ключ распределения: book_ref +-- Обоснование: book_ref — это уникальный идентификатор бронирования. +-- Использование book_ref обеспечивает: +-- 1. Равномерное распределение данных по сегментам (book_ref имеет высокую кардинальность) +-- 2. Коллокацию данных bookings и tickets при JOIN по book_ref +-- 3. Оптимизацию запросов, которые фильтруют или группируют по book_ref DISTRIBUTED BY (book_ref); - diff --git a/sql/stg/bookings_dq.sql b/sql/stg/bookings_dq.sql index 7d8cc06..b8c7f7d 100644 --- a/sql/stg/bookings_dq.sql +++ b/sql/stg/bookings_dq.sql @@ -1,7 +1,7 @@ -- Проверка количества строк между источником stg.bookings_ext и стейджем stg.bookings. -- Считаем строки за то же окно инкремента, что и при загрузке: --- все записи во внешней таблице с book_date больше максимального src_created_at_ts --- из предыдущих батчей должны совпасть по количеству со строками текущего batch_id. +-- все записи во внешней таблице с book_date больше максимального event_ts +-- из предыдущих батчей должны совпасть по количеству со строками текущего _load_id. DO $$ DECLARE @@ -9,13 +9,15 @@ DECLARE v_prev_ts timestamp; v_src_count bigint; v_stg_count bigint; + v_dup_count bigint; + v_null_amount_count bigint; BEGIN - -- Опорная метка: максимум src_created_at_ts среди предыдущих батчей - SELECT max(src_created_at_ts) + -- Опорная метка: максимум event_ts среди предыдущих батчей + SELECT max(event_ts) INTO v_prev_ts FROM stg.bookings - WHERE batch_id <> v_batch_id - OR batch_id IS NULL; + WHERE _load_id <> v_batch_id + OR _load_id IS NULL; -- Источник: считаем строки во внешней таблице, которые вошли в новое окно SELECT COUNT(*) @@ -24,16 +26,32 @@ BEGIN WHERE book_date > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); IF v_src_count = 0 THEN - RAISE EXCEPTION - 'В источнике bookings_ext нет строк для окна инкремента (book_date > %). Проверьте генерацию данных (make bookings-init / make bookings-generate-day или таск generate_bookings_day).', - COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + -- Пустое окно инкремента допустимо: новых данных может не быть. + -- В этом случае ожидаем, что в текущем _load_id тоже 0 строк. + SELECT COUNT(*) + INTO v_stg_count + FROM stg.bookings + WHERE _load_id = v_batch_id; + + IF v_stg_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: источник bookings_ext за окно инкремента пустой, но в stg.bookings есть строки текущего _load_id (_load_id=%): %', + v_batch_id, + v_stg_count; + END IF; + + RAISE NOTICE + 'В источнике bookings_ext нет строк для окна инкремента (book_date > %). Пропускаем DQ проверки (_load_id=%).', + COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'), + v_batch_id; + RETURN; END IF; -- Считаем строки, реально вставленные в stg.bookings в этом батче SELECT COUNT(*) INTO v_stg_count FROM stg.bookings - WHERE batch_id = v_batch_id; + WHERE _load_id = v_batch_id; IF v_src_count <> v_stg_count THEN RAISE EXCEPTION @@ -42,6 +60,33 @@ BEGIN v_stg_count; END IF; + -- Проверка на дубликаты book_ref + SELECT COUNT(*) - COUNT(DISTINCT book_ref) + INTO v_dup_count + FROM stg.bookings AS b + WHERE b._load_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты book_ref (_load_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка на NULL или пустые total_amount + SELECT COUNT(*) + INTO v_null_amount_count + FROM stg.bookings AS b + WHERE b._load_id = v_batch_id + AND (b.total_amount IS NULL OR b.total_amount = ''); + + IF v_null_amount_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены bookings с NULL или пустым total_amount (_load_id=%): %', + v_batch_id, + v_null_amount_count; + END IF; + RAISE NOTICE 'Проверка количества строк пройдена: источник=%, stg=%', v_src_count, diff --git a/sql/stg/bookings_load.sql b/sql/stg/bookings_load.sql index 55cd551..c22b618 100644 --- a/sql/stg/bookings_load.sql +++ b/sql/stg/bookings_load.sql @@ -1,15 +1,22 @@ -- Загрузка инкремента из stg.bookings_ext в stg.bookings. --- Окно инкремента определяется по src_created_at_ts: --- берём строки, где book_date больше максимального src_created_at_ts +-- Окно инкремента определяется по event_ts: +-- берём строки, где book_date больше максимального event_ts -- среди "старых" батчей; верхняя граница по дате не используется. +-- CTE для определения максимальной даты загрузки предыдущего батча +WITH max_batch_ts AS ( + SELECT COALESCE(MAX(event_ts), TIMESTAMP '1900-01-01 00:00:00') AS max_ts + FROM stg.bookings + WHERE _load_id <> '{{ run_id }}'::text + OR _load_id IS NULL +) INSERT INTO stg.bookings ( book_ref, book_date, total_amount, - src_created_at_ts, - load_dttm, - batch_id + event_ts, + _load_ts, + _load_id ) SELECT ext.book_ref::text, @@ -19,18 +26,16 @@ SELECT now(), '{{ run_id }}'::text FROM stg.bookings_ext AS ext -WHERE ext.book_date > COALESCE( - ( - SELECT max(src_created_at_ts) - FROM stg.bookings - WHERE batch_id <> '{{ run_id }}'::text - OR batch_id IS NULL - ), - TIMESTAMP '1900-01-01 00:00:00' -) - AND NOT EXISTS ( - SELECT 1 - FROM stg.bookings AS b - WHERE b.batch_id = '{{ run_id }}'::text - AND b.book_ref = ext.book_ref::text - ); +CROSS JOIN max_batch_ts AS mb +WHERE ext.book_date > mb.max_ts +AND NOT EXISTS ( + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего _load_id. + SELECT 1 + FROM stg.bookings AS b + WHERE b._load_id = '{{ run_id }}'::text + AND b.book_ref = ext.book_ref::text +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.bookings; diff --git a/sql/stg/flights_ddl.sql b/sql/stg/flights_ddl.sql new file mode 100644 index 0000000..cffd5a1 --- /dev/null +++ b/sql/stg/flights_ddl.sql @@ -0,0 +1,42 @@ +-- DDL для слоя STG по таблице flights. +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.flights через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.flights_ext; +CREATE EXTERNAL TABLE stg.flights_ext ( + flight_id TEXT, + route_no TEXT, + status TEXT, + scheduled_departure TIMESTAMP, + scheduled_arrival TIMESTAMP, + actual_departure TIMESTAMP, + actual_arrival TIMESTAMP +) +LOCATION ('pxf://bookings.flights?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.flights — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.flights ( + flight_id TEXT, + route_no TEXT, + status TEXT, + scheduled_departure TEXT, + scheduled_arrival TEXT, + actual_departure TEXT, + actual_arrival TEXT, + event_ts TIMESTAMP, + _load_ts TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +-- Ключ распределения: flight_id +-- Обоснование: flight_id — это уникальный идентификатор рейса. +-- Использование flight_id обеспечивает: +-- 1. Равномерное распределение данных по сегментам (flight_id имеет высокую кардинальность) +-- 2. Оптимизацию запросов, которые фильтруют или группируют по flight_id +-- Примечание: JOIN с таблицами, распределёнными по другим ключам, может требовать motion. +DISTRIBUTED BY (flight_id); diff --git a/sql/stg/flights_dq.sql b/sql/stg/flights_dq.sql new file mode 100644 index 0000000..bdeba8c --- /dev/null +++ b/sql/stg/flights_dq.sql @@ -0,0 +1,113 @@ +-- Проверки качества данных для flights + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_prev_ts TIMESTAMP; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; + v_orphan_route_count BIGINT; +BEGIN + -- Опорная метка: максимум event_ts среди предыдущих батчей + SELECT max(event_ts) + INTO v_prev_ts + FROM stg.flights + WHERE _load_id <> v_batch_id + OR _load_id IS NULL; + + -- Источник: считаем строки во внешней таблице, которые вошли в окно инкремента + SELECT COUNT(*) + INTO v_src_count + FROM stg.flights_ext + WHERE scheduled_departure > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + + IF v_src_count = 0 THEN + -- Пустое окно инкремента допустимо: новых данных может не быть. + -- В этом случае ожидаем, что в текущем _load_id тоже 0 строк. + SELECT COUNT(*) + INTO v_stg_count + FROM stg.flights + WHERE _load_id = v_batch_id; + + IF v_stg_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: источник flights_ext за окно инкремента пустой, но в stg.flights есть строки текущего _load_id (_load_id=%): %', + v_batch_id, + v_stg_count; + END IF; + + RAISE NOTICE + 'В источнике flights_ext нет строк для окна инкремента (scheduled_departure > %). Пропускаем DQ проверки (_load_id=%).', + COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'), + v_batch_id; + RETURN; + END IF; + + -- Считаем строки, реально вставленные в stg.flights в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.flights + WHERE _load_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты flight_id + SELECT COUNT(*) - COUNT(DISTINCT flight_id) + INTO v_dup_count + FROM stg.flights AS f + WHERE f._load_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты flight_id (_load_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей (flight_id, route_no, status, scheduled_departure) + SELECT COUNT(*) + INTO v_null_count + FROM stg.flights AS f + WHERE f._load_id = v_batch_id + AND (f.flight_id IS NULL OR f.flight_id = '' + OR f.route_no IS NULL OR f.route_no = '' + OR f.status IS NULL OR f.status = '' + OR f.scheduled_departure IS NULL OR f.scheduled_departure = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (_load_id=%): %', + v_batch_id, + v_null_count; + END IF; + + -- Проверка ссылочной целостности: все flights должны иметь соответствующие routes + SELECT COUNT(*) + INTO v_orphan_route_count + FROM stg.flights AS f + LEFT JOIN stg.routes AS r + ON f.route_no = r.route_no + AND r._load_id = v_batch_id + WHERE f._load_id = v_batch_id + AND r.route_no IS NULL; + + IF v_orphan_route_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены flights без соответствующих routes (_load_id=%): %', + v_batch_id, + v_orphan_route_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: flights ок (_load_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; diff --git a/sql/stg/flights_load.sql b/sql/stg/flights_load.sql new file mode 100644 index 0000000..d3a47c3 --- /dev/null +++ b/sql/stg/flights_load.sql @@ -0,0 +1,49 @@ +-- Загрузка инкремента из stg.flights_ext в stg.flights. +-- Окно инкремента определяется по event_ts: +-- берём строки, где scheduled_departure больше максимального event_ts +-- среди "старых" батчей; верхняя граница по дате не используется. + +-- CTE для определения максимальной даты загрузки предыдущего батча +WITH max_batch_ts AS ( + SELECT COALESCE(MAX(event_ts), TIMESTAMP '1900-01-01 00:00:00') AS max_ts + FROM stg.flights + WHERE _load_id <> '{{ run_id }}'::text + OR _load_id IS NULL +) +INSERT INTO stg.flights ( + flight_id, + route_no, + status, + scheduled_departure, + scheduled_arrival, + actual_departure, + actual_arrival, + event_ts, + _load_ts, + _load_id +) +SELECT + ext.flight_id::text, + ext.route_no::text, + ext.status::text, + ext.scheduled_departure::text, + ext.scheduled_arrival::text, + ext.actual_departure::text, + ext.actual_arrival::text, + ext.scheduled_departure::timestamp, + now(), + '{{ run_id }}'::text +FROM stg.flights_ext AS ext +CROSS JOIN max_batch_ts AS mb +WHERE ext.scheduled_departure > mb.max_ts +AND NOT EXISTS ( + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего _load_id. + SELECT 1 + FROM stg.flights AS f + WHERE f._load_id = '{{ run_id }}'::text + AND f.flight_id = ext.flight_id::text +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.flights; diff --git a/sql/stg/routes_ddl.sql b/sql/stg/routes_ddl.sql new file mode 100644 index 0000000..2570196 --- /dev/null +++ b/sql/stg/routes_ddl.sql @@ -0,0 +1,45 @@ +-- DDL для слоя STG по таблице routes (справочник). +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.routes через PXF. +-- PXF не поддерживает типы TSTZRANGE, INTEGER[], TIME, INTERVAL - используем TEXT для всех колонок. +DROP EXTERNAL TABLE IF EXISTS stg.routes_ext; +CREATE EXTERNAL TABLE stg.routes_ext ( + route_no TEXT, + validity TEXT, + departure_airport TEXT, + arrival_airport TEXT, + airplane_code TEXT, + days_of_week TEXT, + scheduled_time TEXT, + duration TEXT +) +LOCATION ('pxf://bookings.routes?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.routes — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.routes ( + route_no TEXT, + validity TEXT, + departure_airport TEXT, + arrival_airport TEXT, + airplane_code TEXT, + days_of_week TEXT, + scheduled_time TEXT, + duration TEXT, + event_ts TIMESTAMP, + _load_ts TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +-- Ключ распределения: route_no +-- Обоснование: route_no — бизнес-идентификатор маршрута и часто используется в фильтрах/джойнах. +-- Использование route_no обеспечивает: +-- 1. Равномерное распределение данных по сегментам (route_no имеет высокую кардинальность) +-- 2. Оптимизацию запросов, которые фильтруют или группируют по route_no +-- Примечание: JOIN по airport_code/airplane_code может требовать перераспределения данных (motion). +DISTRIBUTED BY (route_no); diff --git a/sql/stg/routes_dq.sql b/sql/stg/routes_dq.sql new file mode 100644 index 0000000..0a924a7 --- /dev/null +++ b/sql/stg/routes_dq.sql @@ -0,0 +1,123 @@ +-- Проверки качества данных для routes (справочник) + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; + v_orphan_airports_count BIGINT; + v_orphan_airplanes_count BIGINT; +BEGIN + -- Источник: считаем все строки во внешней таблице + SELECT COUNT(*) + INTO v_src_count + FROM stg.routes_ext; + + IF v_src_count = 0 THEN + RAISE EXCEPTION + 'В источнике routes_ext нет строк. Проверьте: bookings-db запущен, PXF работает, STG DDL применён (bookings_stg_ddl или make ddl-gp).'; + END IF; + + -- Считаем строки, реально вставленные в stg.routes в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.routes + WHERE _load_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты составного ключа (route_no, validity) + -- Используем md5 от ROW, чтобы избежать коллизий при склейке строк. + SELECT COUNT(*) - COUNT(DISTINCT md5(ROW(route_no, validity)::text)) + INTO v_dup_count + FROM stg.routes AS r + WHERE r._load_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты (route_no, validity) (_load_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей: route_no, departure_airport, arrival_airport, airplane_code + SELECT COUNT(*) + INTO v_null_count + FROM stg.routes AS r + WHERE r._load_id = v_batch_id + AND (r.route_no IS NULL OR r.route_no = '' + OR r.departure_airport IS NULL OR r.departure_airport = '' + OR r.arrival_airport IS NULL OR r.arrival_airport = '' + OR r.airplane_code IS NULL OR r.airplane_code = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (route_no, departure_airport, arrival_airport, airplane_code) (_load_id=%): %', + v_batch_id, + v_null_count; + END IF; + + -- Проверка ссылочной целостности: departure_airport должен существовать в airports + SELECT COUNT(*) + INTO v_orphan_airports_count + FROM stg.routes AS r + LEFT JOIN stg.airports AS da + ON r.departure_airport = da.airport_code + AND da._load_id = v_batch_id + WHERE r._load_id = v_batch_id + AND da.airport_code IS NULL; + + IF v_orphan_airports_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены routes с несуществующим departure_airport в airports (_load_id=%): %', + v_batch_id, + v_orphan_airports_count; + END IF; + + -- Проверка ссылочной целостности: arrival_airport должен существовать в airports + SELECT COUNT(*) + INTO v_orphan_airports_count + FROM stg.routes AS r + LEFT JOIN stg.airports AS aa + ON r.arrival_airport = aa.airport_code + AND aa._load_id = v_batch_id + WHERE r._load_id = v_batch_id + AND aa.airport_code IS NULL; + + IF v_orphan_airports_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены routes с несуществующим arrival_airport в airports (_load_id=%): %', + v_batch_id, + v_orphan_airports_count; + END IF; + + -- Проверка ссылочной целостности: airplane_code должен существовать в airplanes + SELECT COUNT(*) + INTO v_orphan_airplanes_count + FROM stg.routes AS r + LEFT JOIN stg.airplanes AS a + ON r.airplane_code = a.airplane_code + AND a._load_id = v_batch_id + WHERE r._load_id = v_batch_id + AND a.airplane_code IS NULL; + + IF v_orphan_airplanes_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены routes с несуществующим airplane_code в airplanes (_load_id=%): %', + v_batch_id, + v_orphan_airplanes_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: routes ок (_load_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; diff --git a/sql/stg/routes_load.sql b/sql/stg/routes_load.sql new file mode 100644 index 0000000..fb16869 --- /dev/null +++ b/sql/stg/routes_load.sql @@ -0,0 +1,42 @@ +-- Загрузка всех строк из stg.routes_ext в stg.routes (full load). +-- Используем _load_id для отслеживания загрузки. + +INSERT INTO stg.routes ( + route_no, + validity, + departure_airport, + arrival_airport, + airplane_code, + days_of_week, + scheduled_time, + duration, + event_ts, + _load_ts, + _load_id +) +SELECT + ext.route_no::text, + ext.validity::text, + ext.departure_airport::text, + ext.arrival_airport::text, + ext.airplane_code::text, + ext.days_of_week::text, + ext.scheduled_time::text, + ext.duration::text, + now()::timestamp, + now()::timestamp, + '{{ run_id }}'::text +FROM stg.routes_ext AS ext +WHERE NOT EXISTS ( + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего _load_id. + -- Считаем ключом строки (route_no, validity). + SELECT 1 + FROM stg.routes AS r + WHERE r._load_id = '{{ run_id }}'::text + AND r.route_no = ext.route_no::text + AND r.validity = ext.validity::text +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.routes; diff --git a/sql/stg/seats_ddl.sql b/sql/stg/seats_ddl.sql new file mode 100644 index 0000000..b1f6fa9 --- /dev/null +++ b/sql/stg/seats_ddl.sql @@ -0,0 +1,31 @@ +-- DDL для слоя STG по таблице seats (справочник). +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.seats через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.seats_ext; +CREATE EXTERNAL TABLE stg.seats_ext ( + airplane_code TEXT, + seat_no TEXT, + fare_conditions TEXT +) +LOCATION ('pxf://bookings.seats?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.seats — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.seats ( + airplane_code TEXT, + seat_no TEXT, + fare_conditions TEXT, + event_ts TIMESTAMP, + _load_ts TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +-- Ключ распределения: airplane_code +-- Обоснование: airplane_code обеспечивает коллокацию seats ↔ airplanes при JOIN по airplane_code +-- (в MPP это уменьшает вероятность перераспределения данных / motion). +DISTRIBUTED BY (airplane_code); diff --git a/sql/stg/seats_dq.sql b/sql/stg/seats_dq.sql new file mode 100644 index 0000000..86e55cc --- /dev/null +++ b/sql/stg/seats_dq.sql @@ -0,0 +1,87 @@ +-- Проверки качества данных для seats (справочник) + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; + v_orphan_airplanes_count BIGINT; +BEGIN + -- Источник: считаем все строки во внешней таблице + SELECT COUNT(*) + INTO v_src_count + FROM stg.seats_ext; + + IF v_src_count = 0 THEN + RAISE EXCEPTION + 'В источнике seats_ext нет строк. Проверьте: bookings-db запущен, PXF работает, STG DDL применён (bookings_stg_ddl или make ddl-gp).'; + END IF; + + -- Считаем строки, реально вставленные в stg.seats в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.seats + WHERE _load_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты составного ключа (airplane_code, seat_no) + -- Используем md5 от ROW, чтобы избежать коллизий при склейке строк. + SELECT COUNT(*) - COUNT(DISTINCT md5(ROW(airplane_code, seat_no)::text)) + INTO v_dup_count + FROM stg.seats AS s + WHERE s._load_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты (airplane_code, seat_no) (_load_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей: airplane_code, seat_no, fare_conditions + SELECT COUNT(*) + INTO v_null_count + FROM stg.seats AS s + WHERE s._load_id = v_batch_id + AND (s.airplane_code IS NULL OR s.airplane_code = '' + OR s.seat_no IS NULL OR s.seat_no = '' + OR s.fare_conditions IS NULL OR s.fare_conditions = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (airplane_code, seat_no, fare_conditions) (_load_id=%): %', + v_batch_id, + v_null_count; + END IF; + + -- Проверка ссылочной целостности: airplane_code должен существовать в airplanes + SELECT COUNT(*) + INTO v_orphan_airplanes_count + FROM stg.seats AS s + LEFT JOIN stg.airplanes AS a + ON s.airplane_code = a.airplane_code + AND a._load_id = v_batch_id + WHERE s._load_id = v_batch_id + AND a.airplane_code IS NULL; + + IF v_orphan_airplanes_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены seats с несуществующим airplane_code в airplanes (_load_id=%): %', + v_batch_id, + v_orphan_airplanes_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: seats ок (_load_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; diff --git a/sql/stg/seats_load.sql b/sql/stg/seats_load.sql new file mode 100644 index 0000000..3e53b63 --- /dev/null +++ b/sql/stg/seats_load.sql @@ -0,0 +1,32 @@ +-- Загрузка всех строк из stg.seats_ext в stg.seats (full load). +-- Используем _load_id для отслеживания загрузки. + +INSERT INTO stg.seats ( + airplane_code, + seat_no, + fare_conditions, + event_ts, + _load_ts, + _load_id +) +SELECT + ext.airplane_code::text, + ext.seat_no::text, + ext.fare_conditions::text, + now()::timestamp, + now()::timestamp, + '{{ run_id }}'::text +FROM stg.seats_ext AS ext +WHERE NOT EXISTS ( + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего _load_id. + -- Считаем ключом строки (airplane_code, seat_no). + SELECT 1 + FROM stg.seats AS s + WHERE s._load_id = '{{ run_id }}'::text + AND s.airplane_code = ext.airplane_code::text + AND s.seat_no = ext.seat_no::text +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.seats; diff --git a/sql/stg/segments_ddl.sql b/sql/stg/segments_ddl.sql new file mode 100644 index 0000000..aaae53a --- /dev/null +++ b/sql/stg/segments_ddl.sql @@ -0,0 +1,36 @@ +-- DDL для слоя STG по таблице segments. +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.segments через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.segments_ext; +CREATE EXTERNAL TABLE stg.segments_ext ( + ticket_no TEXT, + flight_id TEXT, + fare_conditions TEXT, + price NUMERIC(10,2) +) +LOCATION ('pxf://bookings.segments?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.segments — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.segments ( + ticket_no TEXT, + flight_id TEXT, + fare_conditions TEXT, + price TEXT, + event_ts TIMESTAMP, + _load_ts TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +-- Ключ распределения: ticket_no +-- Обоснование: ticket_no — это основной бизнес-ключ для билетов. +-- Использование ticket_no обеспечивает: +-- 1. Коллокацию данных segments и boarding_passes при JOIN по ticket_no +-- 2. Равномерное распределение данных по сегментам (ticket_no имеет высокую кардинальность) +-- Примечание: stg.tickets распределена по book_ref, поэтому JOIN segments ↔ tickets по ticket_no может требовать motion. +DISTRIBUTED BY (ticket_no); diff --git a/sql/stg/segments_dq.sql b/sql/stg/segments_dq.sql new file mode 100644 index 0000000..78a7dc2 --- /dev/null +++ b/sql/stg/segments_dq.sql @@ -0,0 +1,130 @@ +-- Проверки качества данных для segments + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_prev_ts TIMESTAMP; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; + v_orphan_ticket_count BIGINT; + v_orphan_flight_count BIGINT; +BEGIN + -- Опорная метка: максимум event_ts среди предыдущих батчей + SELECT max(event_ts) + INTO v_prev_ts + FROM stg.segments + WHERE _load_id <> v_batch_id + OR _load_id IS NULL; + + -- Источник: считаем строки во внешней таблице, которые вошли в окно инкремента + SELECT COUNT(*) + INTO v_src_count + FROM stg.segments_ext AS s + JOIN stg.tickets_ext AS t ON s.ticket_no = t.ticket_no + JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref + WHERE b.book_date > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + + IF v_src_count = 0 THEN + -- Пустое окно инкремента допустимо: новых данных может не быть. + -- В этом случае ожидаем, что в текущем _load_id тоже 0 строк. + SELECT COUNT(*) + INTO v_stg_count + FROM stg.segments + WHERE _load_id = v_batch_id; + + IF v_stg_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: источник segments_ext за окно инкремента пустой, но в stg.segments есть строки текущего _load_id (_load_id=%): %', + v_batch_id, + v_stg_count; + END IF; + + RAISE NOTICE + 'В источнике segments_ext нет строк для окна инкремента (book_date > %). Пропускаем DQ проверки (_load_id=%).', + COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'), + v_batch_id; + RETURN; + END IF; + + -- Считаем строки, реально вставленные в stg.segments в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.segments + WHERE _load_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты (ticket_no, flight_id) + -- Используем md5 от ROW, чтобы избежать коллизий при склейке строк. + SELECT COUNT(*) - COUNT(DISTINCT md5(ROW(ticket_no, flight_id)::text)) + INTO v_dup_count + FROM stg.segments AS s + WHERE s._load_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты (ticket_no, flight_id) (_load_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей (ticket_no, flight_id, fare_conditions, price) + SELECT COUNT(*) + INTO v_null_count + FROM stg.segments AS s + WHERE s._load_id = v_batch_id + AND (s.ticket_no IS NULL OR s.ticket_no = '' + OR s.flight_id IS NULL OR s.flight_id = '' + OR s.fare_conditions IS NULL OR s.fare_conditions = '' + OR s.price IS NULL OR s.price = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (_load_id=%): %', + v_batch_id, + v_null_count; + END IF; + + -- Проверка ссылочной целостности: все segments должны иметь соответствующие tickets + SELECT COUNT(*) + INTO v_orphan_ticket_count + FROM stg.segments AS s + LEFT JOIN stg.tickets AS t ON s.ticket_no = t.ticket_no + WHERE s._load_id = v_batch_id + AND t.ticket_no IS NULL; + + IF v_orphan_ticket_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены segments без соответствующих tickets (_load_id=%): %', + v_batch_id, + v_orphan_ticket_count; + END IF; + + -- Проверка ссылочной целостности: все segments должны иметь соответствующие flights + SELECT COUNT(*) + INTO v_orphan_flight_count + FROM stg.segments AS s + LEFT JOIN stg.flights AS f ON s.flight_id = f.flight_id + WHERE s._load_id = v_batch_id + AND f.flight_id IS NULL; + + IF v_orphan_flight_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены segments без соответствующих flights (_load_id=%): %', + v_batch_id, + v_orphan_flight_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: segments ок (_load_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; diff --git a/sql/stg/segments_load.sql b/sql/stg/segments_load.sql new file mode 100644 index 0000000..1c90dc4 --- /dev/null +++ b/sql/stg/segments_load.sql @@ -0,0 +1,46 @@ +-- Загрузка инкремента из stg.segments_ext в stg.segments. +-- Инкремент определяется по дате бронирования (book_date из bookings.bookings) +-- через JOIN с таблицей tickets. + +-- CTE для определения максимальной даты загрузки предыдущего батча +WITH max_batch_ts AS ( + SELECT COALESCE(MAX(event_ts), TIMESTAMP '1900-01-01 00:00:00') AS max_ts + FROM stg.segments + WHERE _load_id <> '{{ run_id }}'::text + OR _load_id IS NULL +) +INSERT INTO stg.segments ( + ticket_no, + flight_id, + fare_conditions, + price, + event_ts, + _load_ts, + _load_id +) +SELECT + ext.ticket_no, + ext.flight_id, + ext.fare_conditions, + ext.price::text, + b.book_date::timestamp, + now(), + '{{ run_id }}'::text +FROM stg.segments_ext AS ext +JOIN stg.tickets_ext AS t ON ext.ticket_no = t.ticket_no +JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref +CROSS JOIN max_batch_ts AS mb +WHERE b.book_date > mb.max_ts +AND NOT EXISTS ( + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего _load_id. + -- Считаем ключом строки (ticket_no, flight_id). + SELECT 1 + FROM stg.segments AS s + WHERE s._load_id = '{{ run_id }}'::text + AND s.ticket_no = ext.ticket_no + AND s.flight_id = ext.flight_id +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.segments; diff --git a/sql/stg/tickets_ddl.sql b/sql/stg/tickets_ddl.sql new file mode 100644 index 0000000..bdf3a14 --- /dev/null +++ b/sql/stg/tickets_ddl.sql @@ -0,0 +1,38 @@ +-- DDL для слоя STG по таблице tickets. +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.tickets через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.tickets_ext; +CREATE EXTERNAL TABLE stg.tickets_ext ( + ticket_no TEXT, + book_ref TEXT, + passenger_id TEXT, + passenger_name TEXT, + outbound TEXT +) +LOCATION ('pxf://bookings.tickets?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.tickets — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.tickets ( + ticket_no TEXT NOT NULL, + book_ref TEXT NOT NULL, + passenger_id TEXT, + passenger_name TEXT, + outbound TEXT, + event_ts TIMESTAMP, + _load_ts TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL +) +WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1) +-- Ключ распределения: book_ref +-- Обоснование: book_ref — это основной бизнес-ключ для бронирований. +-- Использование book_ref обеспечивает: +-- 1. Коллокацию данных tickets и bookings при JOIN по book_ref +-- 2. Равномерное распределение данных по сегментам (book_ref имеет высокую кардинальность) +-- 3. Оптимизацию запросов, которые фильтруют или группируют по book_ref +DISTRIBUTED BY (book_ref); diff --git a/sql/stg/tickets_dq.sql b/sql/stg/tickets_dq.sql new file mode 100644 index 0000000..caae980 --- /dev/null +++ b/sql/stg/tickets_dq.sql @@ -0,0 +1,126 @@ +-- Проверки качества данных для tickets + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_prev_ts TIMESTAMP; + v_source_count BIGINT; + v_stg_count BIGINT; + v_orphan_count BIGINT; + v_null_count BIGINT; + v_dup_count BIGINT; + v_empty_name_count BIGINT; +BEGIN + -- Опорная метка: максимум event_ts среди предыдущих батчей + SELECT max(event_ts) + INTO v_prev_ts + FROM stg.tickets + WHERE _load_id <> v_batch_id + OR _load_id IS NULL; + + -- Количество в источнике (новые билеты в том же окне инкремента, что и загрузка) + SELECT COUNT(*) + INTO v_source_count + FROM stg.tickets_ext AS t + JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref + WHERE b.book_date > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + + IF v_source_count = 0 THEN + -- Пустое окно инкремента допустимо: новых данных может не быть. + -- В этом случае ожидаем, что в текущем _load_id тоже 0 строк. + SELECT COUNT(*) + INTO v_stg_count + FROM stg.tickets + WHERE _load_id = v_batch_id; + + IF v_stg_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: источник tickets_ext за окно инкремента пустой, но в stg.tickets есть строки текущего _load_id (_load_id=%): %', + v_batch_id, + v_stg_count; + END IF; + + RAISE NOTICE + 'В источнике tickets_ext нет строк для окна инкремента (book_date > %). Пропускаем DQ проверки (_load_id=%).', + COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'), + v_batch_id; + RETURN; + END IF; + + -- Количество в STG (текущий батч) + SELECT COUNT(*) + INTO v_stg_count + FROM stg.tickets + WHERE _load_id = v_batch_id; + + IF v_source_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества билетов. Источник: %, STG (_load_id=%): %', + v_source_count, + v_batch_id, + v_stg_count; + END IF; + + -- Проверка ссылочной целостности: все tickets должны иметь соответствующие bookings в этом же STG + -- Используем LEFT JOIN вместо NOT EXISTS для лучшей производительности на больших объёмах + SELECT COUNT(*) + INTO v_orphan_count + FROM stg.tickets AS t + LEFT JOIN stg.bookings AS b ON t.book_ref = b.book_ref + WHERE t._load_id = v_batch_id + AND b.book_ref IS NULL; + + IF v_orphan_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены tickets без соответствующих bookings (_load_id=%): %', + v_batch_id, + v_orphan_count; + END IF; + + -- Проверка обязательных полей + SELECT COUNT(*) + INTO v_null_count + FROM stg.tickets AS t + WHERE t._load_id = v_batch_id + AND (t.ticket_no IS NULL OR t.book_ref IS NULL); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены tickets с NULL в обязательных полях (_load_id=%): %', + v_batch_id, + v_null_count; + END IF; + + -- Проверка на дубликаты ticket_no + SELECT COUNT(*) - COUNT(DISTINCT ticket_no) + INTO v_dup_count + FROM stg.tickets AS t + WHERE t._load_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты ticket_no (_load_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка на пустые passenger_name + SELECT COUNT(*) + INTO v_empty_name_count + FROM stg.tickets AS t + WHERE t._load_id = v_batch_id + AND (t.passenger_name IS NULL OR t.passenger_name = ''); + + IF v_empty_name_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены tickets с пустым именем пассажира (_load_id=%): %', + v_batch_id, + v_empty_name_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: tickets ок (_load_id=%): source=% stg=%', + v_batch_id, + v_source_count, + v_stg_count; +END $$; diff --git a/sql/stg/tickets_load.sql b/sql/stg/tickets_load.sql new file mode 100644 index 0000000..551aee0 --- /dev/null +++ b/sql/stg/tickets_load.sql @@ -0,0 +1,43 @@ +-- Загрузка инкремента из stg.tickets_ext в stg.tickets +-- Инкремент определяется по дате бронирования (book_date из bookings.bookings) + +-- CTE для определения максимальной даты загрузки предыдущего батча +WITH max_batch_ts AS ( + SELECT COALESCE(MAX(event_ts), TIMESTAMP '1900-01-01 00:00:00') AS max_ts + FROM stg.tickets + WHERE _load_id <> '{{ run_id }}'::text + OR _load_id IS NULL +) +INSERT INTO stg.tickets ( + ticket_no, + book_ref, + passenger_id, + passenger_name, + outbound, + event_ts, + _load_ts, + _load_id +) +SELECT + ext.ticket_no, + ext.book_ref, + ext.passenger_id, + ext.passenger_name, + ext.outbound, + b.book_date::timestamp, -- временная метка из бронирования + now(), + '{{ run_id }}'::text +FROM stg.tickets_ext AS ext +JOIN stg.bookings_ext AS b ON ext.book_ref = b.book_ref +CROSS JOIN max_batch_ts AS mb +WHERE b.book_date > mb.max_ts +AND NOT EXISTS ( + -- Идемпотентность: ticket_no — бизнес-ключ билета, не вставляем его повторно (включая ретраи/повторные запуски DAG). + SELECT 1 + FROM stg.tickets AS t + WHERE t.ticket_no = ext.ticket_no +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.tickets; diff --git a/sql/truncate_gp.sql b/sql/truncate_gp.sql new file mode 100644 index 0000000..69c4b9a --- /dev/null +++ b/sql/truncate_gp.sql @@ -0,0 +1,40 @@ +-- Скрипт для полной очистки всех слоев DWH (STG, ODS, DDS, DM). +-- Используется для сброса состояния перед проведением E2E-тестов. + +-- 1. Слой STG (Сырые данные) +TRUNCATE stg.bookings, + stg.tickets, + stg.airports, + stg.airplanes, + stg.routes, + stg.seats, + stg.flights, + stg.segments, + stg.boarding_passes; + +-- 2. Слой ODS (Текущее состояние, SCD1) +TRUNCATE ods.bookings, + ods.tickets, + ods.airports, + ods.airplanes, + ods.routes, + ods.seats, + ods.flights, + ods.segments, + ods.boarding_passes; + +-- 3. Слой DDS (Схема "Звезда", SCD1/SCD2) +TRUNCATE dds.dim_calendar, + dds.dim_airports, + dds.dim_airplanes, + dds.dim_tariffs, + dds.dim_passengers, + dds.dim_routes, + dds.fact_flight_sales; + +-- 4. Слой DM (Витрины данных) +TRUNCATE dm.sales_report, + dm.route_performance, + dm.airport_traffic, + dm.monthly_overview, + dm.passenger_loyalty; diff --git a/sql/validate/airport_traffic_exists.sql b/sql/validate/airport_traffic_exists.sql new file mode 100644 index 0000000..323ab51 --- /dev/null +++ b/sql/validate/airport_traffic_exists.sql @@ -0,0 +1,23 @@ +-- Проверка dm.airport_traffic: +-- 1. Таблица не пуста +-- 2. Нет NULL в ключевых полях (traffic_date, airport_sk) +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 $$; diff --git a/sql/validate/dim_airplanes_exists.sql b/sql/validate/dim_airplanes_exists.sql new file mode 100644 index 0000000..912bc10 --- /dev/null +++ b/sql/validate/dim_airplanes_exists.sql @@ -0,0 +1,41 @@ +-- Проверка dds.dim_airplanes: +-- 1. Таблица не пуста +-- 2. Нет дублей по airplane_bk (SCD1 — UPSERT должен это гарантировать) +-- 3. Покрытие ODS: все airplane_code из ods.airplanes есть в измерении +-- 4. total_seats заполнен (вычисляется агрегацией из ods.seats; NULL = потерян JOIN) +DO $$ +DECLARE + v_count BIGINT; + v_dup BIGINT; + v_missing BIGINT; + v_null_seats 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; + + -- total_seats вычисляется через JOIN с ods.seats; если JOIN забыт — будет NULL + SELECT COUNT(*) INTO v_null_seats + FROM dds.dim_airplanes WHERE total_seats IS NULL; + IF v_null_seats > 0 THEN + RAISE EXCEPTION E'FAILED: % строк в dds.dim_airplanes имеют NULL в total_seats.\n' + 'Подсказка: total_seats считается агрегацией из ods.seats — проверьте JOIN в dim_airplanes_load.sql.', v_null_seats; + END IF; + + RAISE NOTICE 'PASSED: dds.dim_airplanes содержит % строк, покрывает все BK из ODS, total_seats заполнен', v_count; +END $$; diff --git a/sql/validate/dim_passengers_exists.sql b/sql/validate/dim_passengers_exists.sql new file mode 100644 index 0000000..2552311 --- /dev/null +++ b/sql/validate/dim_passengers_exists.sql @@ -0,0 +1,33 @@ +-- Проверка dds.dim_passengers: +-- 1. Таблица не пуста +-- 2. Нет дублей по passenger_id (SCD1 — типичная ошибка: INSERT без EXISTS) +-- 3. Покрытие ODS: все уникальные passenger_id из ods.tickets есть в измерении +DO $$ +DECLARE + v_count BIGINT; + v_dup BIGINT; + v_missing BIGINT; +BEGIN + SELECT COUNT(*) INTO v_count FROM dds.dim_passengers; + IF v_count = 0 THEN + RAISE EXCEPTION 'FAILED: dds.dim_passengers пуста. Реализуйте загрузку: sql/dds/dim_passengers_load.sql'; + END IF; + + SELECT COUNT(*) - COUNT(DISTINCT passenger_id) INTO v_dup FROM dds.dim_passengers; + IF v_dup > 0 THEN + RAISE EXCEPTION E'FAILED: dds.dim_passengers содержит % дублей по passenger_id.\n' + 'Подсказка: INSERT без проверки EXISTS создаёт дубли при повторном запуске.\n' + 'Используйте INSERT ... WHERE NOT EXISTS или ON CONFLICT DO UPDATE.', v_dup; + END IF; + + SELECT COUNT(*) INTO v_missing + FROM (SELECT DISTINCT passenger_id FROM ods.tickets) AS s + WHERE NOT EXISTS ( + SELECT 1 FROM dds.dim_passengers AS d WHERE d.passenger_id = s.passenger_id + ); + IF v_missing > 0 THEN + RAISE EXCEPTION 'FAILED: % пассажиров из ods.tickets отсутствуют в dds.dim_passengers.', v_missing; + END IF; + + RAISE NOTICE 'PASSED: dds.dim_passengers содержит % строк, нет дублей, покрывает все BK из ODS', v_count; +END $$; diff --git a/sql/validate/dim_passengers_no_dup_bk.sql b/sql/validate/dim_passengers_no_dup_bk.sql new file mode 100644 index 0000000..f0dc687 --- /dev/null +++ b/sql/validate/dim_passengers_no_dup_bk.sql @@ -0,0 +1,22 @@ +-- Проверка: нет дублей по passenger_id в dds.dim_passengers. +-- +-- Отдельный таск для точной диагностики — студент сразу видит причину проблемы. +-- Типичная ошибка: INSERT без проверки EXISTS при SCD1-загрузке. +DO $$ +DECLARE + v_dup BIGINT; +BEGIN + SELECT COUNT(*) INTO v_dup + FROM ( + SELECT passenger_id FROM dds.dim_passengers + GROUP BY passenger_id HAVING COUNT(*) > 1 + ) AS d; + + IF v_dup > 0 THEN + RAISE EXCEPTION E'FAILED: dds.dim_passengers содержит % дублирующихся passenger_id.\n' + 'Подсказка: при SCD1 нужно INSERT ... WHERE NOT EXISTS или ON CONFLICT DO NOTHING/UPDATE.\n' + 'Если таск check_dim_passengers_exists тоже упал — начните с него.', v_dup; + END IF; + + RAISE NOTICE 'PASSED: Нет дублей по passenger_id в dds.dim_passengers'; +END $$; diff --git a/sql/validate/dim_routes_exists.sql b/sql/validate/dim_routes_exists.sql new file mode 100644 index 0000000..ad2f201 --- /dev/null +++ b/sql/validate/dim_routes_exists.sql @@ -0,0 +1,48 @@ +-- Проверка dds.dim_routes: +-- 1. Таблица не пуста +-- 2. Покрытие ODS: у каждого route_no из ods.routes есть открытая текущая версия (valid_to IS NULL) +-- 3. SCD2-инвариант: не более одной текущей версии на route_bk (valid_to IS NULL) +-- +-- Почему покрытие проверяем через valid_to IS NULL, а не просто EXISTS: +-- закрытая версия (valid_to IS NOT NULL) без открытой означает «маршрут есть в ODS, +-- но в DDS только архив» — баг вида "старую версию закрыли, новую не вставили". +-- Такой маршрут теряется в point-in-time JOIN'ах и витринах. +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 AND d.valid_to IS NULL + ); + IF v_missing > 0 THEN + RAISE EXCEPTION E'FAILED: % маршрутов из ods.routes не имеют текущей версии в dds.dim_routes (valid_to IS NULL).\n' + 'Подсказка: проверьте, что после UPDATE (закрытие старой версии) выполняется INSERT новой.', 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 $$; diff --git a/sql/validate/dim_routes_no_gaps.sql b/sql/validate/dim_routes_no_gaps.sql new file mode 100644 index 0000000..0a6cbc1 --- /dev/null +++ b/sql/validate/dim_routes_no_gaps.sql @@ -0,0 +1,28 @@ +-- Проверка: нет «дыр» в SCD2-интервалах dim_routes. +-- +-- Для каждого route_bk с несколькими версиями проверяем, что +-- valid_to предыдущей версии = valid_from следующей. +-- +-- Важно: если маршрут «исчез» из ODS, его текущая версия закрывается +-- (valid_to = CURRENT_DATE), но новая не вставляется — это корректно. +-- Проверяем «дыру» только если следующая версия существует. +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 $$; diff --git a/sql/validate/dim_routes_scd2_backup.sql b/sql/validate/dim_routes_scd2_backup.sql new file mode 100644 index 0000000..3daf917 --- /dev/null +++ b/sql/validate/dim_routes_scd2_backup.sql @@ -0,0 +1,12 @@ +-- Бэкап текущего состояния перед активным тестом 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; diff --git a/sql/validate/dim_routes_scd2_check.sql b/sql/validate/dim_routes_scd2_check.sql new file mode 100644 index 0000000..3b7d02d --- /dev/null +++ b/sql/validate/dim_routes_scd2_check.sql @@ -0,0 +1,95 @@ +-- Проверяем, что 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 $$; diff --git a/sql/validate/dim_routes_scd2_mutate.sql b/sql/validate/dim_routes_scd2_mutate.sql new file mode 100644 index 0000000..ab93d27 --- /dev/null +++ b/sql/validate/dim_routes_scd2_mutate.sql @@ -0,0 +1,35 @@ +-- Мутация: сдвигаем 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 $$; diff --git a/sql/validate/dim_routes_scd2_restore.sql b/sql/validate/dim_routes_scd2_restore.sql new file mode 100644 index 0000000..8ff75f6 --- /dev/null +++ b/sql/validate/dim_routes_scd2_restore.sql @@ -0,0 +1,42 @@ +-- Откат данных после активного теста SCD2. +-- Выполняется ВСЕГДА (trigger_rule="all_done"), даже если check упал. +-- +-- Безопасность: если backup-шаг не создал таблицы (сбой на backup), +-- откат НЕ трогает live-данные — просто чистит служебные таблицы. +-- Это гарантирует, что restore никогда не сломает ods.routes / dds.dim_routes. +-- +-- Паттерн: setup → act → assert → teardown (стандарт интеграционных тестов). + +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; diff --git a/sql/validate/monthly_overview_exists.sql b/sql/validate/monthly_overview_exists.sql new file mode 100644 index 0000000..be4d52e --- /dev/null +++ b/sql/validate/monthly_overview_exists.sql @@ -0,0 +1,23 @@ +-- Проверка dm.monthly_overview: +-- 1. Таблица не пуста +-- 2. Нет NULL в ключевых полях (year_actual, month_actual, airplane_sk) +DO $$ +DECLARE + v_count BIGINT; + v_null_pk BIGINT; +BEGIN + SELECT COUNT(*) INTO v_count FROM dm.monthly_overview; + IF v_count = 0 THEN + RAISE EXCEPTION 'FAILED: dm.monthly_overview пуста. Реализуйте загрузку: sql/dm/monthly_overview_load.sql'; + END IF; + + SELECT COUNT(*) INTO v_null_pk + FROM dm.monthly_overview + WHERE year_actual IS NULL OR month_actual IS NULL OR airplane_sk IS NULL; + + IF v_null_pk > 0 THEN + RAISE EXCEPTION 'FAILED: dm.monthly_overview содержит % строк с NULL в ключе (year_actual, month_actual, airplane_sk).', v_null_pk; + END IF; + + RAISE NOTICE 'PASSED: dm.monthly_overview содержит % строк', v_count; +END $$; diff --git a/sql/validate/ods_airplanes_rowcount.sql b/sql/validate/ods_airplanes_rowcount.sql new file mode 100644 index 0000000..18af086 --- /dev/null +++ b/sql/validate/ods_airplanes_rowcount.sql @@ -0,0 +1,56 @@ +-- Проверка: ODS airplanes содержит все BK из STG-батча. +-- +-- Логика: ODS — TRUNCATE+INSERT snapshot. STG — append-only история всех батчей. +-- Сравниваем не счётчики строк, а точное множество BK для того батча, +-- который ODS фактически загрузил (определяем по _load_id из ods.airplanes). +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 $$; diff --git a/sql/validate/ods_no_dup_bk.sql b/sql/validate/ods_no_dup_bk.sql new file mode 100644 index 0000000..27e23fd --- /dev/null +++ b/sql/validate/ods_no_dup_bk.sql @@ -0,0 +1,55 @@ +-- Проверка ODS-инвариантов: +-- 1. Нет дублей по BK в ods.airplanes и ods.seats. +-- 2. Обе таблицы загружены из одного согласованного батча (_load_id совпадают). +-- +-- Почему важна согласованность батча: +-- ods.airplanes и ods.seats — части одного snapshot'а (TRUNCATE+INSERT из одного STG-батча). +-- Если они собраны из разных батчей (например, airplanes перезагрузили, а seats — нет), +-- JOIN между ними даст неконсистентный срез и невалидный total_seats в dim_airplanes. +DO $$ +DECLARE + v_dup_airplanes BIGINT; + v_dup_seats BIGINT; + v_load_id_airplanes TEXT; + v_load_id_seats TEXT; +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; + + -- Согласованность батча: обе таблицы должны быть загружены из одного _load_id + SELECT DISTINCT _load_id INTO v_load_id_airplanes FROM ods.airplanes; + SELECT DISTINCT _load_id INTO v_load_id_seats FROM ods.seats; + + IF v_load_id_airplanes IS NOT NULL + AND v_load_id_seats IS NOT NULL + AND v_load_id_airplanes <> v_load_id_seats + THEN + RAISE EXCEPTION E'FAILED: ods.airplanes и ods.seats загружены из разных батчей.\n' + ' airplanes._load_id = %\n' + ' seats._load_id = %\n' + 'ODS — единый snapshot: обе таблицы должны содержать один и тот же _load_id.\n' + 'Запустите оба load-скрипта (airplanes_load.sql и seats_load.sql) в одном прогоне.', + v_load_id_airplanes, v_load_id_seats; + END IF; + + RAISE NOTICE 'PASSED: Нет дублей по BK в ods.airplanes и ods.seats; батч согласован (%)', v_load_id_airplanes; +END $$; diff --git a/sql/validate/ods_no_null_pks.sql b/sql/validate/ods_no_null_pks.sql new file mode 100644 index 0000000..afd18ca --- /dev/null +++ b/sql/validate/ods_no_null_pks.sql @@ -0,0 +1,26 @@ +-- Проверка: PK-поля не содержат NULL в ods.airplanes и ods.seats. +-- +-- NULL в PK ломает JOIN'ы и агрегаты в DDS/DM — такие строки «теряются» тихо. +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 $$; diff --git a/sql/validate/ods_seats_rowcount.sql b/sql/validate/ods_seats_rowcount.sql new file mode 100644 index 0000000..5deb0e7 --- /dev/null +++ b/sql/validate/ods_seats_rowcount.sql @@ -0,0 +1,55 @@ +-- Проверка: ODS seats содержит все BK из STG-батча. +-- +-- Аналог ods_airplanes_rowcount.sql, но для seats с составным BK (airplane_code, seat_no). +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.seats; + IF v_ods_count = 0 THEN + RAISE EXCEPTION 'FAILED: ods.seats пуста. Реализуйте загрузку: sql/ods/seats_load.sql'; + END IF; + + -- Инвариант: ODS после TRUNCATE+INSERT содержит ровно один _load_id + SELECT COUNT(DISTINCT _load_id) INTO v_batch_count FROM ods.seats; + IF v_batch_count <> 1 THEN + RAISE EXCEPTION 'FAILED: ods.seats содержит % разных _load_id (ожидается 1 после TRUNCATE+INSERT). Проверьте, что load начинается с TRUNCATE.', v_batch_count; + END IF; + + SELECT DISTINCT _load_id INTO v_batch FROM ods.seats; + + -- BK есть в STG-батче, но нет в ODS (потеряны при загрузке) + SELECT COUNT(*) INTO v_missing_in_ods + FROM ( + SELECT DISTINCT airplane_code, seat_no FROM stg.seats WHERE _load_id = v_batch + ) AS stg_bk + WHERE NOT EXISTS ( + SELECT 1 FROM ods.seats AS o + WHERE o.airplane_code = stg_bk.airplane_code AND o.seat_no = stg_bk.seat_no + ); + + IF v_missing_in_ods > 0 THEN + RAISE EXCEPTION 'FAILED: % мест из STG-батча (%) отсутствуют в ods.seats. Проверьте логику 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, seat_no FROM ods.seats + ) AS ods_bk + WHERE NOT EXISTS ( + SELECT 1 FROM stg.seats AS s + WHERE s._load_id = v_batch AND s.airplane_code = ods_bk.airplane_code AND s.seat_no = ods_bk.seat_no + ); + + IF v_extra_in_ods > 0 THEN + RAISE EXCEPTION 'FAILED: % мест в ods.seats отсутствуют в STG-батче (%). Возможно, TRUNCATE не выполнился перед INSERT.', v_extra_in_ods, v_batch; + END IF; + + RAISE NOTICE 'PASSED: ods.seats содержит % мест, множество BK = STG-батч %', v_ods_count, v_batch; +END $$; diff --git a/sql/validate/passenger_loyalty_exists.sql b/sql/validate/passenger_loyalty_exists.sql new file mode 100644 index 0000000..4cda129 --- /dev/null +++ b/sql/validate/passenger_loyalty_exists.sql @@ -0,0 +1,23 @@ +-- Проверка dm.passenger_loyalty: +-- 1. Таблица не пуста +-- 2. Нет NULL в ключевом поле (passenger_sk) +DO $$ +DECLARE + v_count BIGINT; + v_null_pk BIGINT; +BEGIN + SELECT COUNT(*) INTO v_count FROM dm.passenger_loyalty; + IF v_count = 0 THEN + RAISE EXCEPTION 'FAILED: dm.passenger_loyalty пуста. Реализуйте загрузку: sql/dm/passenger_loyalty_load.sql'; + END IF; + + SELECT COUNT(*) INTO v_null_pk + FROM dm.passenger_loyalty + WHERE passenger_sk IS NULL; + + IF v_null_pk > 0 THEN + RAISE EXCEPTION 'FAILED: dm.passenger_loyalty содержит % строк с NULL в ключе (passenger_sk).', v_null_pk; + END IF; + + RAISE NOTICE 'PASSED: dm.passenger_loyalty содержит % строк', v_count; +END $$; diff --git a/sql/validate/route_performance_exists.sql b/sql/validate/route_performance_exists.sql new file mode 100644 index 0000000..4e4593e --- /dev/null +++ b/sql/validate/route_performance_exists.sql @@ -0,0 +1,23 @@ +-- Проверка dm.route_performance: +-- 1. Таблица не пуста +-- 2. Нет NULL в ключевом поле (route_bk) +DO $$ +DECLARE + v_count BIGINT; + v_null_pk BIGINT; +BEGIN + SELECT COUNT(*) INTO v_count FROM dm.route_performance; + IF v_count = 0 THEN + RAISE EXCEPTION 'FAILED: dm.route_performance пуста. Реализуйте загрузку: sql/dm/route_performance_load.sql'; + END IF; + + SELECT COUNT(*) INTO v_null_pk + FROM dm.route_performance + WHERE route_bk IS NULL; + + IF v_null_pk > 0 THEN + RAISE EXCEPTION 'FAILED: dm.route_performance содержит % строк с NULL в ключе (route_bk).', v_null_pk; + END IF; + + RAISE NOTICE 'PASSED: dm.route_performance содержит % строк', v_count; +END $$; diff --git a/tests/conftest.py b/tests/conftest.py index 1d5d066..24854e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,14 +55,3 @@ def _ensure_stub_module(full_name: str) -> ModuleType: module = sys.modules[path] assert isinstance(module, ModuleType) return module - - -def patch_postgres_hook(monkeypatch, hook_cls: Type) -> None: - """ - Patch PostgresHook so that helpers.greenplum can be exercised without real Airflow. - """ - try: - module = importlib.import_module("airflow.providers.postgres.hooks.postgres") - except ModuleNotFoundError: - module = _ensure_stub_module("airflow.providers.postgres.hooks.postgres") - monkeypatch.setattr(module, "PostgresHook", hook_cls, raising=False) diff --git a/tests/test_dags_smoke.py b/tests/test_dags_smoke.py index 2d2f727..f88c68c 100644 --- a/tests/test_dags_smoke.py +++ b/tests/test_dags_smoke.py @@ -25,48 +25,442 @@ def _load_dag(module_name: str): return getattr(mod, "dag") -def test_csv_to_greenplum_dag_structure(): - dag = _load_dag("airflow.dags.csv_to_greenplum") +def _assert_direct_edge(dag, upstream_task_id: str, downstream_task_id: str) -> None: + upstream = dag.get_task(upstream_task_id) + downstream = dag.get_task(downstream_task_id) + assert downstream in upstream.get_direct_relatives( + upstream=False + ), f"Expected direct edge {upstream_task_id} -> {downstream_task_id}" + + +def _assert_reachable(dag, upstream_task_id: str, downstream_task_id: str) -> None: + upstream = dag.get_task(upstream_task_id) + downstream = dag.get_task(downstream_task_id) + assert downstream in upstream.get_flat_relatives( + upstream=False + ), f"Expected {downstream_task_id} to be downstream of {upstream_task_id}" + + +def test_bookings_stg_ddl_dag_structure(): + """Проверка структуры DAG bookings_stg_ddl.""" + dag = _load_dag("airflow.dags.bookings_stg_ddl") - # tasks expected_tasks = { - "create_orders_table", - "generate_csv", - "preview_csv", - "load_csv_to_greenplum", + "apply_stg_bookings_ddl", + "apply_stg_tickets_ddl", + "apply_stg_airports_ddl", + "apply_stg_airplanes_ddl", + "apply_stg_routes_ddl", + "apply_stg_seats_ddl", + "apply_stg_flights_ddl", + "apply_stg_segments_ddl", + "apply_stg_boarding_passes_ddl", } assert expected_tasks.issubset(dag.task_dict.keys()) - # linear dependencies - t1 = dag.get_task("create_orders_table") - t2 = dag.get_task("generate_csv") - t3 = dag.get_task("preview_csv") - t4 = dag.get_task("load_csv_to_greenplum") + # Smoke-test графа: проверяем ключевые инварианты, не фиксируя линейный порядок. + # Это позволяет в будущем распараллеливать независимые DDL-задачи. + _assert_reachable(dag, "apply_stg_bookings_ddl", "apply_stg_tickets_ddl") - assert t2 in t1.get_direct_relatives("downstream") - assert t3 in t2.get_direct_relatives("downstream") - assert t4 in t3.get_direct_relatives("downstream") + for task_id in expected_tasks - {"apply_stg_bookings_ddl"}: + _assert_reachable(dag, "apply_stg_bookings_ddl", task_id) -def test_csv_to_greenplum_dq_dag_structure(): - dag = _load_dag("airflow.dags.csv_to_greenplum_dq") +def test_bookings_to_gp_stage_dag_structure(): + """Проверка структуры DAG bookings_to_gp_stage.""" + dag = _load_dag("airflow.dags.bookings_to_gp_stage") expected_tasks = { - "check_orders_table_exists", - "check_orders_schema", - "check_orders_has_rows", - "check_order_duplicates", - "data_quality_summary", + "generate_bookings_day", + "load_bookings_to_stg", + "check_row_counts", + "load_tickets_to_stg", + "check_tickets_dq", + "load_airports_to_stg", + "check_airports_dq", + "load_airplanes_to_stg", + "check_airplanes_dq", + "load_routes_to_stg", + "check_routes_dq", + "load_seats_to_stg", + "check_seats_dq", + "load_flights_to_stg", + "check_flights_dq", + "load_segments_to_stg", + "check_segments_dq", + "load_boarding_passes_to_stg", + "check_boarding_passes_dq", + "finish_summary", } assert expected_tasks.issubset(dag.task_dict.keys()) - e = dag.get_task("check_orders_table_exists") - s = dag.get_task("check_orders_schema") - h = dag.get_task("check_orders_has_rows") - d = dag.get_task("check_order_duplicates") - q = dag.get_task("data_quality_summary") + # Smoke-test графа: проверяем инварианты, не фиксируя линейный порядок. + # Это позволяет в будущем распараллеливать независимые загрузки справочников/транзакций. - assert s in e.get_direct_relatives("downstream") - assert h in s.get_direct_relatives("downstream") - assert d in h.get_direct_relatives("downstream") - assert q in d.get_direct_relatives("downstream") + # Базовая цепочка должна сохраниться: генерация → bookings → DQ → tickets → DQ. + _assert_reachable(dag, "generate_bookings_day", "load_bookings_to_stg") + _assert_reachable(dag, "load_bookings_to_stg", "check_row_counts") + _assert_reachable(dag, "check_row_counts", "load_tickets_to_stg") + _assert_reachable(dag, "load_tickets_to_stg", "check_tickets_dq") + + # Инвариант "load → dq" для каждой таблицы. + load_to_dq = [ + ("load_bookings_to_stg", "check_row_counts"), + ("load_tickets_to_stg", "check_tickets_dq"), + ("load_airports_to_stg", "check_airports_dq"), + ("load_airplanes_to_stg", "check_airplanes_dq"), + ("load_routes_to_stg", "check_routes_dq"), + ("load_seats_to_stg", "check_seats_dq"), + ("load_flights_to_stg", "check_flights_dq"), + ("load_segments_to_stg", "check_segments_dq"), + ("load_boarding_passes_to_stg", "check_boarding_passes_dq"), + ] + for load_task_id, dq_task_id in load_to_dq: + _assert_direct_edge(dag, load_task_id, dq_task_id) + + # Барьеры по данным (не обязательно прямые рёбра). + # routes_dq использует airports/airplanes текущего _load_id. + _assert_reachable(dag, "check_airports_dq", "check_routes_dq") + _assert_reachable(dag, "check_airplanes_dq", "check_routes_dq") + + # seats_dq использует airplanes текущего _load_id. + _assert_reachable(dag, "check_airplanes_dq", "check_seats_dq") + + # flights_dq использует routes текущего _load_id. + _assert_reachable(dag, "check_routes_dq", "check_flights_dq") + + # segments_dq проверяет наличие flights (STG-история); для первой загрузки flights должны быть до segments. + _assert_reachable(dag, "check_flights_dq", "check_segments_dq") + + # boarding_passes_dq проверяет наличие segments/tickets (STG-история); для первой загрузки segments должны быть до DQ. + _assert_reachable(dag, "check_segments_dq", "check_boarding_passes_dq") + + # Финальная сводка должна быть в конце графа (обе ветки). + _assert_reachable(dag, "check_boarding_passes_dq", "finish_summary") + _assert_reachable(dag, "check_seats_dq", "finish_summary") + + # Параллельность: airports и airplanes оба downstream от check_tickets_dq, + # но НЕ зависят друг от друга (ни прямо, ни транзитивно). + _assert_reachable(dag, "check_tickets_dq", "load_airports_to_stg") + _assert_reachable(dag, "check_tickets_dq", "load_airplanes_to_stg") + + airports = dag.get_task("load_airports_to_stg") + airplanes = dag.get_task("load_airplanes_to_stg") + assert airplanes not in airports.get_flat_relatives( + upstream=False + ), "airports не должен быть upstream для airplanes" + assert airports not in airplanes.get_flat_relatives( + upstream=False + ), "airplanes не должен быть upstream для airports" + + +def test_bookings_ods_ddl_dag_structure(): + """Проверка структуры DAG bookings_ods_ddl.""" + dag = _load_dag("airflow.dags.bookings_ods_ddl") + + expected_tasks = { + "apply_ods_airports_ddl", + "apply_ods_airplanes_ddl", + "apply_ods_routes_ddl", + "apply_ods_seats_ddl", + "apply_ods_bookings_ddl", + "apply_ods_tickets_ddl", + "apply_ods_flights_ddl", + "apply_ods_segments_ddl", + "apply_ods_boarding_passes_ddl", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + _assert_reachable(dag, "apply_ods_airports_ddl", "apply_ods_airplanes_ddl") + + for task_id in expected_tasks - {"apply_ods_airports_ddl"}: + _assert_reachable(dag, "apply_ods_airports_ddl", task_id) + + +def test_bookings_to_gp_ods_dag_structure(): + """Проверка структуры DAG bookings_to_gp_ods.""" + dag = _load_dag("airflow.dags.bookings_to_gp_ods") + + expected_tasks = { + "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_seats", + "dq_ods_seats", + "load_ods_flights", + "dq_ods_flights", + "load_ods_segments", + "dq_ods_segments", + "load_ods_boarding_passes", + "dq_ods_boarding_passes", + "finish_ods_summary", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + # Базовая цепочка транзакций. + _assert_reachable(dag, "resolve_stg_batch_id", "load_ods_bookings") + _assert_reachable(dag, "load_ods_bookings", "dq_ods_bookings") + _assert_reachable(dag, "dq_ods_bookings", "load_ods_tickets") + _assert_reachable(dag, "load_ods_tickets", "dq_ods_tickets") + + # Инвариант "load -> dq" для каждой таблицы. + load_to_dq = [ + ("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_seats", "dq_ods_seats"), + ("load_ods_flights", "dq_ods_flights"), + ("load_ods_segments", "dq_ods_segments"), + ("load_ods_boarding_passes", "dq_ods_boarding_passes"), + ] + for load_task_id, dq_task_id in load_to_dq: + _assert_direct_edge(dag, load_task_id, dq_task_id) + + # Справочники стартуют параллельно и не зависят друг от друга. + _assert_reachable(dag, "resolve_stg_batch_id", "load_ods_airports") + _assert_reachable(dag, "resolve_stg_batch_id", "load_ods_airplanes") + airports = dag.get_task("load_ods_airports") + airplanes = dag.get_task("load_ods_airplanes") + assert airplanes not in airports.get_flat_relatives( + upstream=False + ), "airports не должен быть upstream для airplanes" + assert airports not in airplanes.get_flat_relatives( + upstream=False + ), "airplanes не должен быть upstream для airports" + + # Барьеры по данным. + _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") + _assert_reachable(dag, "dq_ods_tickets", "dq_ods_segments") + _assert_reachable(dag, "dq_ods_segments", "dq_ods_boarding_passes") + + # Финальная сводка должна ждать обе ветки. + _assert_reachable(dag, "dq_ods_boarding_passes", "finish_ods_summary") + _assert_reachable(dag, "dq_ods_seats", "finish_ods_summary") + + +def test_bookings_dds_ddl_dag_structure(): + """Проверка структуры DAG bookings_dds_ddl.""" + dag = _load_dag("airflow.dags.bookings_dds_ddl") + + expected_tasks = { + "apply_dds_dim_calendar_ddl", + "apply_dds_dim_airports_ddl", + "apply_dds_dim_airplanes_ddl", + "apply_dds_dim_tariffs_ddl", + "apply_dds_dim_passengers_ddl", + "apply_dds_dim_routes_ddl", + "apply_dds_fact_flight_sales_ddl", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + _assert_reachable(dag, "apply_dds_dim_calendar_ddl", "apply_dds_dim_airports_ddl") + + for task_id in expected_tasks - {"apply_dds_dim_calendar_ddl"}: + _assert_reachable(dag, "apply_dds_dim_calendar_ddl", task_id) + + +def test_bookings_to_gp_dds_dag_structure(): + """Проверка структуры DAG bookings_to_gp_dds.""" + dag = _load_dag("airflow.dags.bookings_to_gp_dds") + + expected_tasks = { + "load_dds_dim_calendar", + "dq_dds_dim_calendar", + "load_dds_dim_airports", + "dq_dds_dim_airports", + "load_dds_dim_airplanes", + "dq_dds_dim_airplanes", + "load_dds_dim_tariffs", + "dq_dds_dim_tariffs", + "load_dds_dim_passengers", + "dq_dds_dim_passengers", + "load_dds_dim_routes", + "dq_dds_dim_routes", + "load_dds_fact_flight_sales", + "dq_dds_fact_flight_sales", + "finish_dds_summary", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + load_to_dq = [ + ("load_dds_dim_calendar", "dq_dds_dim_calendar"), + ("load_dds_dim_airports", "dq_dds_dim_airports"), + ("load_dds_dim_airplanes", "dq_dds_dim_airplanes"), + ("load_dds_dim_tariffs", "dq_dds_dim_tariffs"), + ("load_dds_dim_passengers", "dq_dds_dim_passengers"), + ("load_dds_dim_routes", "dq_dds_dim_routes"), + ("load_dds_fact_flight_sales", "dq_dds_fact_flight_sales"), + ] + for load_task_id, dq_task_id in load_to_dq: + _assert_direct_edge(dag, load_task_id, dq_task_id) + + # После calendar все остальные измерения должны быть reachable. + _assert_reachable(dag, "dq_dds_dim_calendar", "load_dds_dim_airports") + _assert_reachable(dag, "dq_dds_dim_calendar", "load_dds_dim_airplanes") + _assert_reachable(dag, "dq_dds_dim_calendar", "load_dds_dim_tariffs") + _assert_reachable(dag, "dq_dds_dim_calendar", "load_dds_dim_passengers") + _assert_reachable(dag, "dq_dds_dim_calendar", "load_dds_dim_routes") + + # Параллельность: airports и airplanes не должны зависеть друг от друга. + airports = dag.get_task("load_dds_dim_airports") + airplanes = dag.get_task("load_dds_dim_airplanes") + assert airplanes not in airports.get_flat_relatives( + upstream=False + ), "dds airports не должен быть upstream для airplanes" + assert airports not in airplanes.get_flat_relatives( + upstream=False + ), "dds airplanes не должен быть upstream для airports" + + # 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") + + # Факт должен стартовать только после всех измерений. + _assert_reachable(dag, "dq_dds_dim_calendar", "load_dds_fact_flight_sales") + _assert_reachable(dag, "dq_dds_dim_airports", "load_dds_fact_flight_sales") + _assert_reachable(dag, "dq_dds_dim_airplanes", "load_dds_fact_flight_sales") + _assert_reachable(dag, "dq_dds_dim_tariffs", "load_dds_fact_flight_sales") + _assert_reachable(dag, "dq_dds_dim_passengers", "load_dds_fact_flight_sales") + _assert_reachable(dag, "dq_dds_dim_routes", "load_dds_fact_flight_sales") + + # Финальная сводка должна ждать DQ факта. + _assert_reachable(dag, "dq_dds_fact_flight_sales", "finish_dds_summary") + + +def test_bookings_dm_ddl_dag_structure(): + """Проверка структуры DAG bookings_dm_ddl.""" + dag = _load_dag("airflow.dags.bookings_dm_ddl") + + expected_tasks = { + "apply_dm_sales_report_ddl", + "apply_dm_route_performance_ddl", + "apply_dm_passenger_loyalty_ddl", + "apply_dm_airport_traffic_ddl", + "apply_dm_monthly_overview_ddl", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + # Проверяем линейную цепочку + _assert_direct_edge( + dag, "apply_dm_sales_report_ddl", "apply_dm_route_performance_ddl" + ) + _assert_direct_edge( + dag, "apply_dm_route_performance_ddl", "apply_dm_passenger_loyalty_ddl" + ) + _assert_direct_edge( + dag, "apply_dm_passenger_loyalty_ddl", "apply_dm_airport_traffic_ddl" + ) + _assert_direct_edge( + dag, "apply_dm_airport_traffic_ddl", "apply_dm_monthly_overview_ddl" + ) + + +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" + + +def test_bookings_to_gp_dm_dag_structure(): + """Проверка структуры DAG bookings_to_gp_dm.""" + dag = _load_dag("airflow.dags.bookings_to_gp_dm") + + expected_tasks = { + "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", + "load_dm_airport_traffic", + "dq_dm_airport_traffic", + "load_dm_monthly_overview", + "dq_dm_monthly_overview", + "finish_dm_summary", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + marts = [ + "sales_report", + "route_performance", + "passenger_loyalty", + "airport_traffic", + "monthly_overview", + ] + + for mart in marts: + # load -> dq + _assert_direct_edge(dag, f"load_dm_{mart}", f"dq_dm_{mart}") + # start -> load + _assert_reachable(dag, "start_dm", f"load_dm_{mart}") + # dq -> finish + _assert_reachable(dag, f"dq_dm_{mart}", "finish_dm_summary") diff --git a/tests/test_greenplum_helpers.py b/tests/test_greenplum_helpers.py deleted file mode 100644 index a60876c..0000000 --- a/tests/test_greenplum_helpers.py +++ /dev/null @@ -1,178 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any, List, Sequence - -import pytest - -import airflow.dags.helpers.greenplum as greenplum -from tests.conftest import patch_postgres_hook - - -@dataclass -class FakeCursor: - fetchone_value: Any = None - fetchall_value: Sequence[Any] | None = None - rowcount: int | None = None - - def __post_init__(self) -> None: - self.queries: List[Any] = [] - - def execute(self, query: str, params: Any | None = None) -> None: - self.queries.append((query, params)) - - def fetchone(self) -> Any: - return self.fetchone_value - - def fetchall(self) -> Sequence[Any] | None: - return self.fetchall_value - - def __enter__(self) -> FakeCursor: - return self - - def __exit__(self, exc_type, exc, tb) -> None: - return None - - -class FakeConn: - def __init__(self, cursors: Sequence[FakeCursor]) -> None: - self._cursors = list(cursors) - self._index = 0 - self.commits = 0 - - def cursor(self) -> FakeCursor: - cursor = self._cursors[self._index] - self._index += 1 - return cursor - - def commit(self) -> None: - self.commits += 1 - - -def test_get_gp_conn_uses_airflow_hook(monkeypatch) -> None: - class FakeHook: - def __init__(self, postgres_conn_id: str) -> None: - self.postgres_conn_id = postgres_conn_id - - def get_conn(self) -> str: - return "hook_connection" - - patch_postgres_hook(monkeypatch, FakeHook) - monkeypatch.setattr(greenplum, "GP_CONN_ID", "demo_conn", raising=False) - monkeypatch.setattr(greenplum, "GP_USE_AIRFLOW_CONN", True, raising=False) - - conn = greenplum.get_gp_conn() - - assert conn == "hook_connection" - - -def test_get_gp_conn_fallback_to_psycopg(monkeypatch) -> None: - class BrokenHook: - def __init__(self, postgres_conn_id: str) -> None: - self.postgres_conn_id = postgres_conn_id - - def get_conn(self): - raise RuntimeError("boom") - - patch_postgres_hook(monkeypatch, BrokenHook) - monkeypatch.setattr(greenplum, "GP_USE_AIRFLOW_CONN", True, raising=False) - monkeypatch.setattr(greenplum, "GP_CONN_ID", "demo_conn", raising=False) - monkeypatch.setenv("GP_DB", "demo_db") - monkeypatch.setenv("GP_USER", "demo_user") - monkeypatch.setenv("GP_PASSWORD", "secret") - monkeypatch.setenv("GP_HOST", "greenplum-host") - monkeypatch.setenv("GP_PORT", "5434") - - captured_kwargs = {} - - def fake_connect(**kwargs): - captured_kwargs.update(kwargs) - return "psycopg_connection" - - monkeypatch.setattr(greenplum.psycopg2, "connect", fake_connect) - - conn = greenplum.get_gp_conn() - - assert conn == "psycopg_connection" - assert captured_kwargs == { - "dbname": "demo_db", - "user": "demo_user", - "password": "secret", - "host": "greenplum-host", - "port": 5434, - } - - -def test_get_gp_conn_without_airflow(monkeypatch) -> None: - monkeypatch.setattr(greenplum, "GP_USE_AIRFLOW_CONN", False, raising=False) - monkeypatch.setenv("GP_DB", "demo_db") - monkeypatch.setenv("GP_USER", "demo_user") - monkeypatch.setenv("GP_PASSWORD", "secret") - monkeypatch.setenv("GP_HOST", "greenplum-host") - monkeypatch.setenv("GP_PORT", "5435") - - captured_kwargs = {} - - def fake_connect(**kwargs): - captured_kwargs.update(kwargs) - return "direct_psycopg" - - monkeypatch.setattr(greenplum.psycopg2, "connect", fake_connect) - - conn = greenplum.get_gp_conn() - - assert conn == "direct_psycopg" - assert captured_kwargs["port"] == 5435 - - -def test_assert_orders_table_exists_ok() -> None: - conn = FakeConn([FakeCursor(fetchone_value=(1,))]) - - greenplum.assert_orders_table_exists(conn) - - -def test_assert_orders_table_exists_missing() -> None: - conn = FakeConn([FakeCursor(fetchone_value=None)]) - - with pytest.raises(ValueError): - greenplum.assert_orders_table_exists(conn) - - -def test_assert_orders_schema_ok() -> None: - expected = list(greenplum.EXPECTED_ORDERS_SCHEMA) - conn = FakeConn([FakeCursor(fetchall_value=expected)]) - - greenplum.assert_orders_schema(conn) - - -def test_assert_orders_schema_mismatch() -> None: - conn = FakeConn([FakeCursor(fetchall_value=[("order_id", "bigint")])]) - - with pytest.raises(ValueError): - greenplum.assert_orders_schema(conn) - - -def test_assert_orders_have_rows_ok() -> None: - conn = FakeConn([FakeCursor(fetchone_value=(5,))]) - - greenplum.assert_orders_have_rows(conn) - - -def test_assert_orders_have_rows_empty() -> None: - conn = FakeConn([FakeCursor(fetchone_value=(0,))]) - - with pytest.raises(ValueError): - greenplum.assert_orders_have_rows(conn) - - -def test_assert_orders_no_duplicates_ok() -> None: - conn = FakeConn([FakeCursor(fetchone_value=(0,))]) - - greenplum.assert_orders_no_duplicates(conn) - - -def test_assert_orders_no_duplicates_detected() -> None: - conn = FakeConn([FakeCursor(fetchone_value=(3,))]) - - with pytest.raises(ValueError): - greenplum.assert_orders_no_duplicates(conn) diff --git a/tests/test_ods_snapshot_integration.py b/tests/test_ods_snapshot_integration.py new file mode 100644 index 0000000..2fb49e2 --- /dev/null +++ b/tests/test_ods_snapshot_integration.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +RUN_ODS_INTEGRATION = os.getenv("RUN_ODS_INTEGRATION") == "1" +BATCH_TOKEN = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}' + +pytestmark = pytest.mark.skipif( + not RUN_ODS_INTEGRATION, + reason="Set RUN_ODS_INTEGRATION=1 to run ODS integration tests", +) + + +def _psql(sql: str) -> str: + """Выполняет SQL в Greenplum-контейнере и возвращает stdout psql.""" + docker_bin = os.getenv("DOCKER_BIN", "docker") + cmd = [ + docker_bin, + "compose", + "-f", + "docker-compose.yml", + "exec", + "-T", + "greenplum", + "bash", + "-lc", + "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -v ON_ERROR_STOP=1 -d gp_dwh -At -f -'", + ] + result = subprocess.run( + cmd, + cwd=PROJECT_ROOT, + input=sql, + text=True, + capture_output=True, + check=True, + ) + return result.stdout + + +def _render_airports_sql( + path: str, batch_id: str, stg_table: str, ods_table: str +) -> str: + sql = (PROJECT_ROOT / path).read_text(encoding="utf-8") + sql = sql.replace(BATCH_TOKEN, batch_id) + sql = sql.replace("stg.airports", stg_table) + sql = sql.replace("ods.airports", ods_table) + return sql + + +def test_snapshot_airports_contract_upsert_delete_and_dq() -> None: + """ + Интеграционный тест контракта snapshot-таблицы: + - UPSERT обновляет и вставляет; + - DELETE синхронизирует current state по выбранному батчу; + - DQ проходит на корректном состоянии. + """ + stg_table = "public.it_stg_airports_ods" + ods_table = "public.it_ods_airports_ods" + + setup_sql = f""" + DROP TABLE IF EXISTS {stg_table}; + DROP TABLE IF EXISTS {ods_table}; + + CREATE TABLE {stg_table} ( + airport_code TEXT, + airport_name TEXT, + city TEXT, + country TEXT, + coordinates TEXT, + timezone TEXT, + event_ts TIMESTAMP, + _load_ts TIMESTAMP, + _load_id TEXT + ); + + CREATE TABLE {ods_table} ( + airport_code TEXT NOT NULL, + airport_name TEXT NOT NULL, + city TEXT NOT NULL, + country TEXT NOT NULL, + coordinates TEXT, + timezone TEXT NOT NULL, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() + ); + """ + _psql(setup_sql) + + try: + _psql( + f""" + INSERT INTO {stg_table} ( + airport_code, airport_name, city, country, coordinates, timezone, + event_ts, _load_ts, _load_id + ) VALUES + ('AAA', '{{"en": "Airport A", "ru": "Аэропорт A"}}', '{{"en": "City A", "ru": "Город A"}}', '{{"en": "Country A", "ru": "Страна A"}}', '(0,0)', 'UTC', now(), now(), 'batch_1'), + ('BBB', '{{"en": "Airport B", "ru": "Аэропорт B"}}', '{{"en": "City B", "ru": "Город B"}}', '{{"en": "Country B", "ru": "Страна B"}}', '(1,1)', 'UTC', now(), now(), 'batch_1'); + """ + ) + + _psql( + _render_airports_sql( + "sql/ods/airports_load.sql", "batch_1", stg_table, ods_table + ) + ) + + codes_batch_1 = _psql( + f"SELECT COALESCE(string_agg(airport_code, ',' ORDER BY airport_code), '') FROM {ods_table};" + ).strip() + assert codes_batch_1 == "AAA,BBB" + + _psql( + f""" + INSERT INTO {stg_table} ( + airport_code, airport_name, city, country, coordinates, timezone, + event_ts, _load_ts, _load_id + ) VALUES + ('AAA', '{{"en": "Airport A v2", "ru": "Аэропорт A v2"}}', '{{"en": "City A", "ru": "Город A"}}', '{{"en": "Country A", "ru": "Страна A"}}', '(0,0)', 'UTC', now(), now(), 'batch_2'), + ('CCC', '{{"en": "Airport C", "ru": "Аэропорт C"}}', '{{"en": "City C", "ru": "Город C"}}', '{{"en": "Country C", "ru": "Страна C"}}', '(2,2)', 'UTC', now(), now(), 'batch_2'); + """ + ) + + _psql( + _render_airports_sql( + "sql/ods/airports_load.sql", "batch_2", stg_table, ods_table + ) + ) + + codes_batch_2 = _psql( + f"SELECT COALESCE(string_agg(airport_code, ',' ORDER BY airport_code), '') FROM {ods_table};" + ).strip() + assert codes_batch_2 == "AAA,CCC" + + # ODS теперь содержит русские названия (извлечены из JSON) + airport_a_name = _psql( + f"SELECT airport_name FROM {ods_table} WHERE airport_code = 'AAA';" + ).strip() + assert airport_a_name == "Аэропорт A v2" + + _psql( + _render_airports_sql( + "sql/ods/airports_dq.sql", "batch_2", stg_table, ods_table + ) + ) + finally: + _psql(f"DROP TABLE IF EXISTS {stg_table}; DROP TABLE IF EXISTS {ods_table};") diff --git a/tests/test_ods_sql_contract.py b/tests/test_ods_sql_contract.py new file mode 100644 index 0000000..41726ff --- /dev/null +++ b/tests/test_ods_sql_contract.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SNAPSHOT_ENTITIES = ("airports", "airplanes", "routes", "seats") + + +def _read(path: str) -> str: + return (PROJECT_ROOT / path).read_text(encoding="utf-8") + + +def test_snapshot_load_scripts_use_truncate() -> None: + """Snapshot-таблицы в ODS должны использовать паттерн полной перезагрузки (TRUNCATE).""" + for entity in SNAPSHOT_ENTITIES: + sql = _read(f"sql/ods/{entity}_load.sql") + assert f"TRUNCATE TABLE ods.{entity};" in sql + assert f"INSERT INTO ods.{entity} (" in sql + assert "WHERE s.rn = 1;" in sql + + +def test_snapshot_dq_checks_extra_keys() -> None: + """DQ snapshot-таблиц должен ловить лишние ключи в ODS относительно текущего батча STG.""" + for entity in SNAPSHOT_ENTITIES: + sql = _read(f"sql/ods/{entity}_dq.sql") + assert "v_extra_keys_count" in sql + assert "найдены лишние ключи" in sql + + +def test_ods_batch_resolver_uses_consistent_snapshot_batches() -> None: + """Резолвер батча должен искать _load_id, общий для всех snapshot-таблиц STG.""" + dag_code = _read("airflow/dags/bookings_to_gp_ods.py") + + for table_name in ("stg.airports", "stg.airplanes", "stg.routes", "stg.seats"): + assert table_name in dag_code + + assert "INTERSECT" in dag_code + assert "candidate_batches" in dag_code + + +def test_flights_load_covers_segment_flight_ids_from_stg_history() -> None: + """ + Загрузка flights должна подтягивать рейсы из истории STG, + если на них ссылаются segments текущего батча. + """ + sql = _read("sql/ods/flights_load.sql") + + assert "segment_flights" in sql + assert "FROM stg.segments AS s" in sql + assert "JOIN segment_flights AS sf" in sql