Compare commits
155
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bf61d8f74 | ||
|
|
999a8d08bf | ||
|
|
d3f2f42e38 | ||
|
|
a8148c8afa | ||
|
|
f053fe1e35 | ||
|
|
5cb4bab010 | ||
|
|
330911c0f1 | ||
|
|
7b6136b0fa | ||
|
|
d6e5cd56c6 | ||
|
|
a51e2a6243 | ||
|
|
23cde47fe7 | ||
|
|
4e9d8f5a1c | ||
|
|
f018f1e7da | ||
|
|
e699934230 | ||
|
|
c9856f0c95 | ||
|
|
84454debe3 | ||
|
|
8932148c44 | ||
|
|
4ce0aa7a23 | ||
|
|
f157c9142b | ||
|
|
21f62be01a | ||
|
|
6655326caa | ||
|
|
5972bcc2d8 | ||
|
|
3de4db8aa6 | ||
|
|
a207f8d281 | ||
|
|
815a248841 | ||
|
|
0ee2d81383 | ||
|
|
c81bdb86d9 | ||
|
|
85d2776b44 | ||
|
|
78fb8e372c | ||
|
|
ae4aee3f66 | ||
|
|
a6a3559ab9 | ||
|
|
45c1d37da5 | ||
|
|
df8c2c36fa | ||
|
|
d77da5686c | ||
|
|
f26ed7875c | ||
|
|
583d832511 | ||
|
|
d89434c3a8 | ||
|
|
68654181d4 | ||
|
|
2228312b55 | ||
|
|
f68b055b00 | ||
|
|
a06b52c38d | ||
|
|
491f1e68d6 | ||
|
|
22a0590e1b | ||
|
|
ab97d6a535 | ||
|
|
dd6274b948 | ||
|
|
4d793a9c11 | ||
|
|
92f3828a45 | ||
|
|
2937185cfa | ||
|
|
daeae7d612 | ||
|
|
483ed30882 | ||
|
|
cfd20328d6 | ||
|
|
faf410b4e8 | ||
|
|
768334453f | ||
|
|
e29a249830 | ||
|
|
8870ba9088 | ||
|
|
f2f0468cb1 | ||
|
|
81ff396402 | ||
|
|
84ec949ff9 | ||
|
|
a8cce6cd6b | ||
|
|
20a1270633 | ||
|
|
c6ac41c3f6 | ||
|
|
016cc3d43d | ||
|
|
5efb833837 | ||
|
|
7d18b3fe5c | ||
|
|
e577f31a67 | ||
|
|
b6b87a7a00 | ||
|
|
e592b35461 | ||
|
|
fc798aadd1 | ||
|
|
0dfecf4cd8 | ||
|
|
3ddf3cc136 | ||
|
|
6f8dd9c7c7 | ||
|
|
85fea19818 | ||
|
|
2d629b6e80 | ||
|
|
105c4ac31c | ||
|
|
9b1e853d99 | ||
|
|
9e5ce91bcc | ||
|
|
5dfd9d3368 | ||
|
|
9f8098edd5 | ||
|
|
2c80955eb6 | ||
|
|
39ff430ed0 | ||
|
|
3dcaeb85ce | ||
|
|
159ee99a32 | ||
|
|
6ce7915806 | ||
|
|
31dde678c5 | ||
|
|
16e8a883b8 | ||
|
|
d4c91c4eec | ||
|
|
bb83c831d2 | ||
|
|
4b119fbd7c | ||
|
|
7312cdfb68 | ||
|
|
7abeb67fba | ||
|
|
9b8a6f3ce4 | ||
|
|
733f14eee3 | ||
|
|
ece3873f54 | ||
|
|
1812ced796 | ||
|
|
52031c75e6 | ||
|
|
941601a257 | ||
|
|
d6fed5c7b9 | ||
|
|
33110eed48 | ||
|
|
256d594ac8 | ||
|
|
ad663efcea | ||
|
|
897a76588d | ||
|
|
c23bbed10c | ||
|
|
ffd9585ef4 | ||
|
|
a10cbff906 | ||
|
|
09c940ea0e | ||
|
|
25f04fd0f0 | ||
|
|
acdbb8be3e | ||
|
|
e986222d46 | ||
|
|
8fd5ae1069 | ||
|
|
d25c753eb4 | ||
|
|
9dbfc3572d | ||
|
|
e98821f28d | ||
|
|
06a5b7bc4c | ||
|
|
e6e923794c | ||
|
|
03765d2808 | ||
|
|
dc1dc9c66a | ||
|
|
7bdb5acfcb | ||
|
|
6ceff7b027 | ||
|
|
d1e81099c7 | ||
|
|
26d0ab3d87 | ||
|
|
2fb60e67ed | ||
|
|
cb5dd0528b | ||
|
|
69651f98ff | ||
|
|
a0258fd63e | ||
|
|
9e08bc181c | ||
|
|
843500cf52 | ||
|
|
e5a9a07f01 | ||
|
|
761cf5dd93 | ||
|
|
8946df4a28 | ||
|
|
ba74b521dc | ||
|
|
762d539d52 | ||
|
|
4718b3bdb7 | ||
|
|
9c033b39aa | ||
|
|
15081384f4 | ||
|
|
457b47e415 | ||
|
|
d525cf56c4 | ||
|
|
520eacbfe2 | ||
|
|
fec67dfad2 | ||
|
|
10a36b6dfb | ||
|
|
9ec7e82f5f | ||
|
|
f36a98497a | ||
|
|
93ba59c1c9 | ||
|
|
875746972c | ||
|
|
464e631eea | ||
|
|
0208e6fd71 | ||
|
|
23581b4f4a | ||
|
|
61103847aa | ||
|
|
cf1438fceb | ||
|
|
2d76789050 | ||
|
|
5f3e32c031 | ||
|
|
16da5d0aae | ||
|
|
db0e0259d6 | ||
|
|
991af3b2c3 | ||
|
|
ff831b6bb2 | ||
|
|
5c3723a8c1 |
No files matched your search
+11
-7
@@ -5,11 +5,15 @@ TZ=Europe/Moscow
|
||||
AIRFLOW_USER=admin
|
||||
AIRFLOW_PASSWORD=admin
|
||||
AIRFLOW__WEBSERVER__SECRET_KEY=your-super-secret-key-here-replace-with-actual-random-key
|
||||
# Порт Airflow UI на хосте
|
||||
AIRFLOW_WEB_PORT=8080
|
||||
|
||||
# PostgreSQL (Airflow metadata)
|
||||
PG_USER=airflow
|
||||
PG_PASSWORD=airflow
|
||||
PG_DB=airflow
|
||||
# Порт мета-БД Airflow на хосте (нужен в основном для диагностики)
|
||||
PGMETA_PORT=5433
|
||||
|
||||
# Bookings demo database (Postgres)
|
||||
BOOKINGS_DB_USER=bookings
|
||||
@@ -18,9 +22,11 @@ BOOKINGS_DB_NAME=bookings
|
||||
BOOKINGS_DB_PORT=5434
|
||||
# Начальная дата модельного времени для генерации демобазы
|
||||
BOOKINGS_START_DATE=2017-01-01
|
||||
# Количество дней для первой генерации (держим малым, чтобы быстрее увидеть данные)
|
||||
BOOKINGS_INIT_DAYS=1
|
||||
# Количество параллельных джобов генератора (по умолчанию 1; при 1 работаем синхронно без dblink)
|
||||
# Количество дней для генерации с нуля (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
|
||||
@@ -29,6 +35,8 @@ GP_PASSWORD=gpadmin
|
||||
GP_DB=gp_dwh
|
||||
GP_HOST=greenplum
|
||||
GP_PORT=5432
|
||||
# Порт Greenplum на хосте (подключение из DBeaver/psql с ноутбука)
|
||||
GP_HOST_PORT=5435
|
||||
GP_CONN_ID=greenplum_conn
|
||||
GP_USE_AIRFLOW_CONN=true
|
||||
|
||||
@@ -37,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
|
||||
@@ -1,46 +1,50 @@
|
||||
# Repository Guidelines (для агентa и контрибьюторов)
|
||||
# Agent System Instructions & Repository Guidelines
|
||||
|
||||
Эта репа — учебный стенд для студентов (менти), которые только начинают с Airflow/Greenplum и Python. Пожалуйста, держите решения простыми, стабильными и хорошо объяснёнными.
|
||||
Этот репозиторий — учебный DWH-стенд (Airflow, Greenplum, Postgres) для начинающих Data инженеров.
|
||||
|
||||
## Структура проекта
|
||||
- `airflow/dags/` — DAG-файлы (например, `csv_to_greenplum.py`, `csv_to_greenplum_dq.py`).
|
||||
- `airflow/requirements.txt` — зависимости, которые ставятся внутри контейнеров Airflow.
|
||||
- `sql/` — DDL и вспомогательные SQL (например, `sql/ddl_gp.sql`).
|
||||
- `docker-compose.yml` — Greenplum, Airflow, Postgres (мета-БД).
|
||||
- `Makefile` — удобные команды для локальной работы.
|
||||
- `.env(.example)` — настройки окружения (реальные секреты не коммитим).
|
||||
## 1. Главное правило генерации кода (Баланс)
|
||||
Создаваемый код должен иметь учебную ценность: быть эталоном для "боевого" применения, но без избыточного усложнения (over-engineering).
|
||||
- **Production-ready:** Учитывайте идемпотентность DAG'ов, транзакционность, отсутствие хардкода секретов.
|
||||
- **KISS:** Не используйте сложные ООП-паттерны, метапрограммирование или избыточные абстракции, если задачу решает стандартный оператор (например, `PostgresOperator`).
|
||||
- **Фокус на «Почему»:** При использовании специфичных паттернов DWH (например, `delete + insert` для инкремента в Greenplum вместо `merge`) — добавляйте краткий комментарий, объясняющий этот выбор студентам.
|
||||
|
||||
## Команды (основные)
|
||||
- `make up` — поднять весь стек (Airflow инициализируется автоматически при первом старте).
|
||||
- `make stop` — остановить контейнеры, не трогая данные.
|
||||
- `make down` — остановить и удалить контейнеры/сети (тома сохраняются).
|
||||
- `make clean` — полный reset: остановить и удалить контейнеры/сети и тома (все данные будут потеряны).
|
||||
- `make airflow-init` — вручную переинициализировать мета-БД Airflow и создать пользователя (обычно не нужно).
|
||||
- `make logs` — логи webserver и scheduler.
|
||||
- `make ddl-gp` — применить DDL к Greenplum.
|
||||
- `make gp-psql` — открыть `psql` в контейнере Greenplum от `gpadmin`.
|
||||
## 2. Карта проекта (Навигация для Агента)
|
||||
- `airflow/dags/` — DAG-файлы (напр. `bookings_to_gp_stage.py`).
|
||||
- `sql/` — DDL и SQL-скрипты. Разделены на слои: src/ (исходные системы), stg/ (стейджинг), ods/ (операционное хранилище), dds/ (детальное хранилище), dm/ (слой витрин).
|
||||
- *Правило ИИ:* DDL таблиц хранится строго рядом с объектом (напр. `sql/stg/bookings_ddl.sql`).
|
||||
- `docs/` — документация. Подкаталоги: `design/` (дизайн, стандарты), `reference/` (справочники), `plans/` (активные планы), `archive/` (выполненные планы), `assignment/` (задание для студента).
|
||||
- `docs/design/naming_conventions.md` — единый источник нейминга служебных и SCD-полей. *Правило ИИ: всегда сверяться при генерации DDL/SQL.*
|
||||
- Нейминг файлов планов: `YYYY-MM-DD_краткое-описание.md` (дата — ISO 8601, `_` отделяет дату от описания, описание в kebab-case, без суффикса `-plan`).
|
||||
- `tests/` — pytest-тесты (smoke-тесты DAG'ов и юнит-тесты).
|
||||
- `.env` — Настройки окружения (все секреты `GP_*`, `AIRFLOW_*` берем только отсюда).
|
||||
|
||||
Пример: `make up`, затем открыть `http://localhost:8080`.
|
||||
## 3. Среда и Инструменты (Терминал)
|
||||
Мы используем **uv** для управления зависимостями и **make** для автоматизации.
|
||||
- *Запрещено* использовать `pip install --user`. Только `uv`.
|
||||
- Если нужно запустить команду в терминале, используйте `uv run ...`
|
||||
- Доступные Make-таргеты (агент может вызывать их для проверок):
|
||||
- `make fmt`, `make lint` — форматирование (`black`, `isort`) и линтинг кода.
|
||||
- `make test` — запуск `pytest`.
|
||||
- `make up` / `make stop` / `make down` — управление контейнерами docker-compose.
|
||||
- `make ddl-gp` — накат DDL на Greenplum.
|
||||
|
||||
## Локальное Python‑окружение
|
||||
- Используем `uv`: достаточно `uv sync` (или `make dev-sync`) — подтянет Python, создаст `.venv`, установит зависимости.
|
||||
- `make dev-setup` полезен при смене версии Python (выполнит `uv python install` + `uv python pin` перед `uv sync`).
|
||||
- Проверки: `make test`, `make lint`, `make fmt` (выполняются через `uv run`).
|
||||
- Не используем `pip install --user`; если что‑то попало в user‑site — удалить `pip uninstall <package>` и проверить `pip list --user`.
|
||||
- В IDE выбираем интерпретатор из `.venv`.
|
||||
## 4. Airflow + SQL Специфика
|
||||
- В учебных DAG'ах основная логика выносится в SQL. По умолчанию используйте `PostgresOperator` + Airflow Connections (`sql='sql/stg/bookings_load.sql'`).
|
||||
- Сложную работу с соединениями (например, `psycopg2` напрямую в Python) используйте только там, где реально много Python-логики и это служит учебной цели.
|
||||
|
||||
## Стиль кода
|
||||
- Python: PEP 8, 4 пробела, `snake_case`; `dag_id` — `lower_snake_case`.
|
||||
- Импорты: stdlib → third‑party → local, по одному модулю в строке.
|
||||
- SQL: ключевые слова UPPERCASE, идентификаторы `snake_case`, завершаем `;`.
|
||||
- Форматирование: `black` (88 cols) и `isort`. Если не уверены — запустите `make fmt`.
|
||||
- Язык: комментарии, docstring и документацию — на русском; имена идентификаторов — на английском.
|
||||
## 5. Стиль и Оформление
|
||||
- **Язык:** Комментарии, docstrings, тексты ошибок — на **русском** языке. Ошибки должны быть дружелюбными и подсказывать студенту, что делать дальше.
|
||||
- **Код:** Переменные, функции, SQL-идентификаторы (`snake_case`) — на английском.
|
||||
- **Коммиты:** Придерживайтесь Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`). Если меняется логика DAG'а, в описании PR (или коммита) указывайте, что именно изменилось.
|
||||
|
||||
### Структура и нейминг 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/`, `sql/dds/`, `sql/dm/` по мере роста стенда.
|
||||
- `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` (проверки качества данных) и т.п.
|
||||
@@ -54,9 +58,10 @@
|
||||
|
||||
## Тестирование
|
||||
- Тесты лежат в `tests/` (pytest). Запуск: `make test`.
|
||||
- Есть юнит‑тесты для `helpers/greenplum.py` и smoke‑тесты DAG‑структуры (`tests/test_dags_smoke.py`).
|
||||
- Есть 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`.
|
||||
@@ -65,10 +70,15 @@
|
||||
- При изменении схемы/поведения — обновляйте `README.md` и `sql/ddl_gp.sql`.
|
||||
|
||||
## Безопасность и конфигурация
|
||||
- Все настройки — через `.env`; креды в коде не хардкодим. Частые переменные: `GP_*`, `PG_*`, `AIRFLOW_*`, `CSV_*`.
|
||||
- `make down` удаляет тома — предупреждайте студентов, что данные пропадут.
|
||||
- Все настройки — через `.env`; креды в коде не хардкодим. Частые переменные: `GP_*`, `PG_*`, `AIRFLOW_*`.
|
||||
- `make clean` удаляет тома — предупреждайте студентов, что данные пропадут.
|
||||
|
||||
## Для агента (особенности аудитории)
|
||||
|
||||
При создании и пересмотре лабораторных сначала прочитайте
|
||||
[развитие лабораторных](docs/design/labs_development.md): аудитория,
|
||||
принятые решения и критерии заданий.
|
||||
|
||||
- Пишите простыми словами. Добавляйте короткие комментарии к нетривиальной логике.
|
||||
- Избегайте больших рефакторингов и сложных паттернов — студенты только начинают.
|
||||
- Ошибки и логи — дружелюбные и понятные (лучше с подсказкой «что сделать дальше»).
|
||||
|
||||
+8
-3
@@ -5,15 +5,20 @@ LABEL maintainer="your-email@example.com"
|
||||
LABEL description="Airflow with Greenplum and Pandas dependencies"
|
||||
LABEL version="1.0"
|
||||
|
||||
# Устанавливаем psql для ручной диагностики внутри контейнера
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копируем requirements в стандартное расположение
|
||||
COPY airflow/requirements.txt /opt/airflow/requirements.txt
|
||||
|
||||
# Устанавливаем зависимости
|
||||
USER airflow
|
||||
RUN pip install --no-cache-dir -r /opt/airflow/requirements.txt \
|
||||
&& pip check \
|
||||
&& rm -rf /tmp/pip-*
|
||||
|
||||
# Переключаемся на пользователя airflow
|
||||
USER airflow
|
||||
|
||||
WORKDIR /opt/airflow
|
||||
@@ -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-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,19 +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-init: bookings-clone-demodb
|
||||
bookings-check-jobs:
|
||||
@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-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 \
|
||||
@@ -80,15 +101,101 @@ bookings-init: 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'
|
||||
|
||||
bookings-generate-day:
|
||||
bookings-generate-day: bookings-check-jobs
|
||||
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 \
|
||||
@@ -127,3 +234,7 @@ clean-venv:
|
||||
|
||||
e2e-smoke:
|
||||
./scripts/e2e_smoke.sh
|
||||
|
||||
e2e-etl:
|
||||
./scripts/e2e_etl.sh
|
||||
|
||||
@@ -1,480 +1,179 @@
|
||||
# airflow-dwh-gp-lab
|
||||
# airflow-dwh-gp-lab
|
||||
|
||||
Учебный стенд: ETL из Postgres в Greenplum с оркестрацией в Airflow.
|
||||
> Этот стенд можно использовать самостоятельно, но он также является курсовой работой
|
||||
> плана обучения [Data Engineering Roadmap](https://github.com/dementev-dev/de-roadmap).
|
||||
> Нужна помощь? Обратитесь к автору материалов — ментору [@dementev_dev](https://t.me/dementev_dev).
|
||||
|
||||
Добро пожаловать в учебный стенд для изучения основ Data Engineering! Этот проект поможет вам освоить ключевые инструменты современных data pipeline: **Airflow** для оркестрации, **pandas/CSV** для подготовки данных, **Postgres** с демобазой **bookings** как источник и **Greenplum** как аналитическую базу данных.
|
||||
Учебный стенд для лабораторных по Data Engineering: **Airflow** оркестрирует загрузку данных из демо‑БД
|
||||
**bookings** (Postgres) в **Greenplum**.
|
||||
|
||||
Если вы проходите стенд как серию лабораторных, смотрите также файл с заданиями: `educational-tasks.md`.
|
||||
## Зачем этот стенд
|
||||
|
||||
## 🎯 Что вы узнаете
|
||||
Основная цель стенда — дать вам удобное место для лабораторных работ и будущей курсовой:
|
||||
вы построите небольшое аналитическое хранилище данных (DWH) на базе **Greenplum** и закрепите навыки:
|
||||
|
||||
- Как настроить локальный стек данных с помощью Docker
|
||||
- Как Airflow управляет workflow и координирует задачи
|
||||
- Как генерировать датасеты через pandas и сохранять их в CSV
|
||||
- Как загружать данные в Greenplum пакетами и избегать дублей
|
||||
- Как проверять качество данных в автоматизированных pipeline
|
||||
- Основы проектирования ETL/ELT процессов
|
||||
- Как работать с демо-БД bookings в Postgres как источником для будущего DWH
|
||||
- моделирования данных (слои DWH, модели, витрины);
|
||||
- построения ETL/ELT;
|
||||
- работы с Airflow и Greenplum.
|
||||
|
||||
## 👩🎓 Для студентов (10‑минутный чек‑лист)
|
||||
В курсовой у нас один источник данных — демо‑БД **bookings**. В стенде уже есть готовые учебные примеры
|
||||
загрузки **bookings → STG → ODS → DDS → DM** в Greenplum, чтобы вы могли сфокусироваться
|
||||
на DWH‑части и не тратить время на инфраструктуру.
|
||||
|
||||
- Установите Docker Desktop и Git.
|
||||
- Скопируйте настройки: `cp .env.example .env`.
|
||||
- Поднимите стенд: `make up` (или `docker compose up -d` — сервис `airflow-init` запустится автоматически при первом старте).
|
||||
- Откройте UI: http://localhost:8080 (admin/admin).
|
||||
- Включите и запустите DAG `csv_to_greenplum`. Дождитесь Success.
|
||||
- Проверьте данные: `make gp-psql` → `SELECT COUNT(*) FROM public.orders;`.
|
||||
- Дополнительно: запустите `csv_to_greenplum_dq` — все проверки должны быть зелёные.
|
||||
## Что внутри
|
||||
|
||||
Если что‑то не работает — смотрите «Типичные проблемы» и «Быстрый reset» ниже.
|
||||
- **Airflow** (UI: http://localhost:8080, по умолчанию; логин/пароль: admin/admin)
|
||||
- **Greenplum** (single‑node для обучения; внешний порт по умолчанию `5435`)
|
||||
- **bookings-db** (Postgres с демо‑БД `demo`; внешний порт по умолчанию `5434`)
|
||||
- **PXF** как “транспорт” между Postgres и Greenplum (уже настроен в образе)
|
||||
|
||||
## 🚀 Быстрый старт (для новичков)
|
||||
## Требования
|
||||
|
||||
### Шаг 1: Подготовка окружения
|
||||
Стенд запускается в Docker и поднимает несколько сервисов (Airflow + Greenplum + 2×Postgres),
|
||||
поэтому важны ресурсы и настройки Docker.
|
||||
|
||||
**Требования:**
|
||||
- Docker Desktop (Windows/Mac) или Docker Engine 24+ (Linux)
|
||||
- Git для клонирования репозитория
|
||||
| Ресурс | Минимум | Рекомендуем |
|
||||
|---|---:|---:|
|
||||
| CPU | 2 ядра | 4+ ядер |
|
||||
| RAM | 8 GB | 16 GB |
|
||||
| Диск | 25–30 GB свободного места | 40+ GB (SSD) |
|
||||
|
||||
> 💡 **Совет:** Если у вас Windows, рекомендуем использовать WSL (Windows Subsystem for Linux) для лучшей совместимости.
|
||||
Работает на **Linux**, а на **Windows** — при использовании **WSL2**.
|
||||
|
||||
### Шаг 2: Настройка проекта
|
||||
Потребуется `make`, Docker (`docker`) и Docker Compose (`docker compose`) или Docker Desktop.
|
||||
Для первого запуска нужен доступ в интернет (скачать образы и зависимости).
|
||||
Порты `8080`, `5433`, `5434`, `5435` должны быть свободны (или измените их в `.env`).
|
||||
Порты настраиваются в `.env`: `AIRFLOW_WEB_PORT`, `PGMETA_PORT`, `BOOKINGS_DB_PORT`, `GP_HOST_PORT`.
|
||||
|
||||
Про PXF и технические детали стенда: [docs/stack.md](docs/stack.md).
|
||||
|
||||
## Быстрый старт (основной сценарий: bookings → STG → ODS → DDS → DM)
|
||||
|
||||
1) Скопируйте настройки:
|
||||
|
||||
```bash
|
||||
# Скопируйте файл настроек
|
||||
cp .env.example .env
|
||||
|
||||
# Запустите стек (это может занять 2-3 минуты при первом запуске)
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Шаг 3: Первый запуск pipeline
|
||||
|
||||
1. Откройте Airflow UI: **http://localhost:8080** (логин/пароль: admin/admin)
|
||||
2. Найдите DAG с названием **csv_to_greenplum**
|
||||
3. Нажмите на переключатель слева от названия DAG, чтобы включить его
|
||||
4. Нажмите кнопку **Trigger** (значок воспроизведения ▶️)
|
||||
|
||||
🎉 **Поздравляем!** Вы только что запустили свой первый data pipeline:
|
||||
- Система сгенерировала 1000 тестовых заказов при помощи pandas
|
||||
- Датасет сохранился в CSV-файл в каталоге `./data`
|
||||
- Airflow загрузил данные из CSV в Greenplum без дублей по `order_id`
|
||||
|
||||
### Шаг 4: Проверка результатов
|
||||
|
||||
**Проверка вручную:**
|
||||
```bash
|
||||
# Подключитесь к Greenplum и проверьте данные
|
||||
docker compose exec greenplum bash -c "su - gpadmin -c 'psql -p 5432 -d gp_dwh'"
|
||||
|
||||
# Внутри psql выполните:
|
||||
\dt # Показать таблицы
|
||||
SELECT count(*) FROM public.orders; # Посчитать записи
|
||||
|
||||
# Посмотреть несколько строк
|
||||
SELECT * FROM public.orders LIMIT 5;
|
||||
```
|
||||
|
||||
CSV-файлы после выполнения DAG остаются в директории `./data`. Их можно открыть любым редактором или изучить через pandas.
|
||||
|
||||
### Быстрый reset
|
||||
|
||||
Если после изменений что‑то «сломалось»:
|
||||
2) Поднимите стенд:
|
||||
|
||||
```bash
|
||||
make down # Остановить и удалить контейнеры/сети (volumes сохраняются)
|
||||
make up
|
||||
# если make не установлен: docker compose up -d
|
||||
```
|
||||
|
||||
Если проблема связана с «грязной» остановкой и данными в томах (например, Greenplum не стартует),
|
||||
используйте полный reset: `make clean && make up` (данные в Docker-томах будут потеряны).
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Подробная настройка (для уверенных пользователей)
|
||||
|
||||
> Если вы впервые запускаете стенд, этот раздел можно пролистать и вернуться к нему позже.
|
||||
|
||||
### Установка Make (опционально)
|
||||
|
||||
Для удобства работы с проектом рекомендуем установить `make`:
|
||||
|
||||
- **Linux (Debian/Ubuntu):** `sudo apt install -y make`
|
||||
- **macOS:** `brew install make`
|
||||
- **Windows:**
|
||||
- WSL: `sudo apt install -y make`
|
||||
- Chocolatey: `choco install make`
|
||||
- Scoop: `scoop install make`
|
||||
|
||||
С `make` команды становятся короче:
|
||||
```bash
|
||||
make up # Запуск стека (включая airflow-init при первом старте)
|
||||
make logs # Просмотр логов
|
||||
make gp-psql # Подключение к Greenplum
|
||||
```
|
||||
|
||||
### Настройка подключения к Greenplum в Airflow
|
||||
|
||||
По умолчанию готовые DAG используют Airflow Connections. В docker-compose они
|
||||
заводятся автоматически через переменные окружения:
|
||||
|
||||
- `AIRFLOW_CONN_GREENPLUM_CONN` — подключение к Greenplum с `conn_id=greenplum_conn`;
|
||||
- `AIRFLOW_CONN_BOOKINGS_DB` — подключение к демо-БД bookings с `conn_id=bookings_db`.
|
||||
|
||||
Такие подключения подхватываются из окружения и могут не отображаться в UI,
|
||||
но для DAG это нормально — `PostgresOperator` найдёт их по `conn_id`.
|
||||
|
||||
При желании вы можете создать или отредактировать подключение вручную в UI:
|
||||
|
||||
1. Airflow UI → **Admin → Connections → Add a new record**
|
||||
2. Заполните поля:
|
||||
- **Conn Id:** `greenplum_conn`
|
||||
- **Conn Type:** `Postgres`
|
||||
- **Host:** `greenplum`
|
||||
- **Schema:** `gp_dwh`
|
||||
- **Login:** `gpadmin`
|
||||
- **Password:** `gpadmin`
|
||||
- **Port:** `5432`
|
||||
|
||||
---
|
||||
|
||||
### Greenplum + PXF: свой образ
|
||||
|
||||
Чтобы избежать проблем с правами и нестабильных запусков, Greenplum собирается
|
||||
из собственного `Dockerfile.greenplum`. В образ вшиты:
|
||||
|
||||
- JDBC-драйвер PostgreSQL;
|
||||
- конфигурация PXF-сервера `bookings-db`;
|
||||
- ensure-скрипт, который при каждом старте контейнера докладывает файлы в `PXF_BASE`.
|
||||
|
||||
Дополнительно при старте контейнера:
|
||||
|
||||
- базовые конфиги PXF копируются в `PXF_BASE/conf` (если их ещё нет);
|
||||
- создаются каталоги `PXF_BASE/run` и `PXF_BASE/logs`;
|
||||
- `CREATE EXTENSION pxf` выполняется автоматически, когда Greenplum становится доступен (с ретраями).
|
||||
|
||||
Healthcheck сервиса `greenplum` учитывает не только готовность Greenplum, но и запуск PXF,
|
||||
а также наличие `extension pxf` — это нужно, чтобы Airflow не стартовал раньше PXF.
|
||||
|
||||
Сборка и запуск:
|
||||
|
||||
- `make build` — собрать образ (явно);
|
||||
- `make up` — поднимет стек и соберёт образ, если он ещё не создан.
|
||||
|
||||
Обновление PXF-конфигов:
|
||||
|
||||
- изменили файлы в `pxf/` → выполните `make build` и перезапустите контейнер;
|
||||
- для принудительной перезаписи файлов в `PXF_BASE` используйте `PXF_SEED_OVERWRITE=1`;
|
||||
- для принудительного `pxf cluster sync` при старте используйте `PXF_SYNC_ON_START=1`.
|
||||
|
||||
Проверка PXF:
|
||||
|
||||
- статус: `docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/pxf/bin/pxf cluster status'"`;
|
||||
- логи: `greenplum_data:/data/pxf/logs` (внутри контейнера — `/data/pxf/logs`).
|
||||
|
||||
---
|
||||
|
||||
### Локальное окружение разработчика
|
||||
|
||||
Локальным окружением управляет [uv](https://docs.astral.sh/uv/) — он скачивает нужный Python и создаёт `.venv` на основе `pyproject.toml` / `uv.lock`.
|
||||
3) Инициализируйте демо‑БД bookings:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
make bookings-init # быстрое восстановление из seed-дампа (~18 сек), рекомендуется
|
||||
# make bookings-generate # альтернатива: полная генерация с нуля (занимает часы)
|
||||
```
|
||||
|
||||
`uv sync` сам подтянет версию Python из `.python-version`/`pyproject.toml`, создаст `.venv` и установит зависимости. Для тех же действий можно использовать `make dev-sync`. Цель `make dev-setup` (или вручную `uv python install` + `uv python pin`) нужна только когда вы меняете версию Python или прогреваете кэш.
|
||||
4) Подготовьте объекты DWH в Greenplum (выберите один вариант):
|
||||
|
||||
> Если требуется «классическое» активированное окружение, после `uv sync` выполните `.\.venv\Scripts\Activate.ps1` в PowerShell или `source .venv/bin/activate` в Unix-терминале.
|
||||
- Учебный вариант: в Airflow UI запустите DAG'и по порядку — `bookings_stg_ddl`, `bookings_ods_ddl`, `bookings_dds_ddl`, `bookings_dm_ddl`;
|
||||
- Технический шорткат: `make ddl-gp` (применяет DDL для STG, ODS, DDS и DM разом).
|
||||
|
||||
Проверки и форматирование выполняем через uv:
|
||||
5) Запустите DAG'и загрузки данных по порядку:
|
||||
|
||||
- `bookings_to_gp_stage` — загрузка в STG
|
||||
- `bookings_to_gp_ods` — загрузка в ODS
|
||||
- `bookings_to_gp_dds` — загрузка в DDS
|
||||
- `bookings_to_gp_dm` — загрузка в DM (витрины)
|
||||
|
||||
6) Проверьте результат в Greenplum:
|
||||
|
||||
```bash
|
||||
make test # uv run pytest -q
|
||||
make lint # black/isort в режиме проверки
|
||||
make fmt # автоформатирование black + isort
|
||||
make gp-psql
|
||||
-- внутри psql:
|
||||
SELECT COUNT(*) FROM stg.bookings;
|
||||
SELECT COUNT(*) FROM stg.tickets;
|
||||
SELECT * FROM stg.bookings ORDER BY event_ts DESC LIMIT 10;
|
||||
SELECT COUNT(*) FROM ods.bookings;
|
||||
SELECT COUNT(*) FROM ods.tickets;
|
||||
SELECT COUNT(*) FROM dds.dim_routes;
|
||||
SELECT COUNT(*) FROM dds.fact_flight_sales;
|
||||
SELECT COUNT(*) FROM dm.sales_report;
|
||||
SELECT COUNT(*) FROM dm.route_performance;
|
||||
```
|
||||
|
||||
#### Быстрый старт с uv
|
||||
Подробнее про логику DAG и проверки — `docs/bookings_to_gp_stage.md`.
|
||||
|
||||
7) Изучите готовую реализацию:
|
||||
|
||||
Весь пайплайн `STG → ODS → DDS → DM` на этой ветке полностью реализован.
|
||||
Переходите к [навигатору по реализации](docs/assignment/README.md) —
|
||||
там описана структура, ключевые паттерны и рекомендуемый порядок изучения.
|
||||
|
||||
## DAG-и в стенде
|
||||
|
||||
Основные (для потока bookings → DWH):
|
||||
|
||||
- `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 (агрегированные витрины).
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run pytest -q
|
||||
uv run black --check airflow tests
|
||||
make up # поднять стек
|
||||
make logs # логи airflow-webserver и airflow-scheduler
|
||||
make gp-psql # psql в Greenplum
|
||||
make bookings-psql # psql в демо-БД bookings (Postgres)
|
||||
make ddl-gp # применить DDL STG+ODS+DDS+DM к Greenplum вручную (вместо DDL-DAG)
|
||||
make down # остановить и удалить контейнеры/сети (volumes сохраняются)
|
||||
make clean # полный reset: удалить контейнеры/сети и volumes (данные будут потеряны)
|
||||
```
|
||||
|
||||
> Не устанавливайте пакеты напрямую через `pip install --user ...`. Если что-то уже попало в user-site, удалите `pip uninstall <package>` и проверьте `pip list --user`.
|
||||
---
|
||||
## Подключение через DBeaver (опционально)
|
||||
|
||||
|
||||
## 📋 Что входит в стенд
|
||||
|
||||
### Основные компоненты
|
||||
- **Greenplum** — аналитическая база данных для хранения и анализа данных
|
||||
- **Airflow** — оркестратор workflow и задач
|
||||
- **Postgres** — база метаданных для Airflow
|
||||
- **Postgres (bookings)** — отдельная демо-БД bookings (источник данных для будущего DWH в Greenplum)
|
||||
- **pandas** — библиотека для генерации и анализа данных в формате CSV
|
||||
|
||||
### Готовые DAG (workflow)
|
||||
- **orders_base_ddl** — создаёт базовую таблицу `public.orders` для CSV‑пайплайна
|
||||
- **bookings_stg_ddl** — готовит схему `stg` и таблицы `stg.bookings_ext` / `stg.bookings`
|
||||
- **csv_to_greenplum** — базовый pipeline: pandas → CSV → Greenplum
|
||||
- **bookings_to_gp_stage** — пример загрузки из демо‑БД bookings в слой STG
|
||||
- **csv_to_greenplum_dq** — проверки качества данных (наличие таблицы, схема, дубликаты)
|
||||
|
||||
> Учебный путь — триггернуть DDL‑DAG: для CSV `orders_base_ddl`, для bookings `bookings_stg_ddl`. Технический шорткат для быстрой инициализации — `make ddl-gp` (он не вызывается автоматически при старте контейнеров).
|
||||
|
||||
### Полезные команды
|
||||
```bash
|
||||
# Основные команды
|
||||
make up # Запустить весь стенд (Airflow инициализируется автоматически при первом старте)
|
||||
make stop # Остановить контейнеры, не трогая данные
|
||||
make down # Остановить и удалить контейнеры/сети (volumes сохраняются)
|
||||
make clean # Полный reset: остановить и удалить контейнеры/сети и тома (данные будут потеряны)
|
||||
make airflow-init # Ручной запуск инициализации Airflow (обычно не нужен)
|
||||
make ddl-gp # Применить DDL к Greenplum вручную
|
||||
make gp-psql # Подключиться к Greenplum через psql
|
||||
make bookings-init # Установить демобазу bookings в Postgres (по умолчанию генерирует 1 день)
|
||||
make bookings-generate-day # Добавить ещё один день данных в bookings (можно вызвать несколько раз)
|
||||
make bookings-psql # Подключиться к демобазе bookings (БД demo)
|
||||
|
||||
# Проверка данных
|
||||
make logs # Следить за логами Airflow
|
||||
|
||||
# Логи задач Airflow сохраняются в Docker-томе `airflow_logs`
|
||||
# и переживают `docker compose down`/`up` (удаляются при `docker compose down -v` / `make clean`).
|
||||
|
||||
# Контроль генерации bookings
|
||||
docker compose -f docker-compose.yml exec bookings-db bash -lc 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d demo -c "SELECT busy();"'
|
||||
# busy() = t — генерация ещё идёт; f — завершена. При необходимости можно вызвать CALL abort(); и запустить генерацию заново.
|
||||
```
|
||||
|
||||
### Генерация следующего дня в bookings
|
||||
|
||||
- При `make bookings-init` автоматически генерируется `BOOKINGS_INIT_DAYS` суток, начиная с даты `BOOKINGS_START_DATE` (по умолчанию один день с `2017-01-01`).
|
||||
- Дальше каждый вызов `make bookings-generate-day` или генерации через DAG добавляет ровно **один** следующий день после `max(book_date)` в `bookings.bookings` — генератор сам смотрит последнюю дату.
|
||||
- Рекомендуемый учебный сценарий для DAG `bookings_to_gp_stage`: запускать DAG по одному дню вперёд, выбирая в форме Trigger логическую дату `Execution Date (ds)`, совпадающую с тем днём, который вы хотите загрузить (например, `2017-01-01`, затем `2017-01-02` и т.д.).
|
||||
|
||||
- Быстрее всего: `make bookings-generate-day` — читает GUC и сам вызывает `continue`.
|
||||
- Вручную из psql/DBeaver (подзапросы в аргументах CALL не работают, поэтому через DO-блок):
|
||||
```sql
|
||||
DO $$
|
||||
DECLARE
|
||||
v_next_day timestamptz;
|
||||
BEGIN
|
||||
SELECT date_trunc('day', max(book_date)) + interval '1 day'
|
||||
INTO v_next_day
|
||||
FROM bookings.bookings;
|
||||
CALL continue(v_next_day); -- или CALL continue(v_next_day, 4) для параллельности
|
||||
END $$;
|
||||
```
|
||||
- Не вызывайте `CALL generate(...)` поверх существующих данных: она делает TRUNCATE и создаёт демобазу заново.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Настройка через переменные окружения
|
||||
|
||||
Все настройки находятся в файле `.env`. Основные параметры:
|
||||
Перед подключением убедитесь, что стенд поднят (`make up`).
|
||||
|
||||
### Greenplum
|
||||
- `GP_USER` — пользователь (по умолчанию: gpadmin)
|
||||
- `GP_PASSWORD` — пароль (по умолчанию: gpadmin)
|
||||
- `GP_DB` — база данных (по умолчанию: gp_dwh)
|
||||
- `GP_PORT` — порт Greenplum внутри Docker-сети (по умолчанию: 5432, менять обычно не нужно; внешний порт на хосте для подключения клиентов — 5435).
|
||||
|
||||
### Демо-БД bookings (Postgres)
|
||||
- `BOOKINGS_DB_USER` — пользователь Postgres для демобазы (по умолчанию: bookings)
|
||||
- `BOOKINGS_DB_PASSWORD` — пароль пользователя (по умолчанию: bookings)
|
||||
- `BOOKINGS_DB_NAME` — база данных, из которой запускается установка генератора (по умолчанию: bookings)
|
||||
- `BOOKINGS_DB_PORT` — внешний порт для подключения к контейнеру bookings-db (по умолчанию: 5434)
|
||||
- `BOOKINGS_START_DATE` — начальная дата модельного времени (по умолчанию: 2017-01-01)
|
||||
- `BOOKINGS_INIT_DAYS` — сколько дней сгенерировать при первой инициализации (по умолчанию: 1, чтобы увидеть данные без долгого ожидания; можно менять при вызове `BOOKINGS_INIT_DAYS=... make bookings-init`)
|
||||
- `BOOKINGS_JOBS` — число параллельных джобов генератора bookings (по умолчанию: 1; при 1 генерация идёт синхронно без dblink)
|
||||
- `Host`: `localhost`
|
||||
- `Port`: значение `GP_HOST_PORT` из `.env` (по умолчанию `5435`)
|
||||
- `Database`: значение `GP_DB` из `.env` (по умолчанию `gp_dwh`)
|
||||
- `Username`: значение `GP_USER` (по умолчанию `gpadmin`)
|
||||
- `Password`: значение `GP_PASSWORD` (по умолчанию `gpadmin`)
|
||||
|
||||
### CSV pipeline
|
||||
- `CSV_DIR` — путь к каталогу с CSV внутри контейнеров Airflow (по умолчанию: `/opt/airflow/data`)
|
||||
- `CSV_ROWS` — количество строк, генерируемых DAG (по умолчанию: 1000)
|
||||
### bookings-db (Postgres, демо-БД `demo`)
|
||||
|
||||
### Airflow
|
||||
- `GP_CONN_ID` — ID подключения (по умолчанию: greenplum_conn)
|
||||
- `Host`: `localhost`
|
||||
- `Port`: значение `BOOKINGS_DB_PORT` из `.env` (по умолчанию `5434`)
|
||||
- `Database`: `demo`
|
||||
- `Username`: значение `BOOKINGS_DB_USER` (по умолчанию `bookings`)
|
||||
- `Password`: значение `BOOKINGS_DB_PASSWORD` (по умолчанию `bookings`)
|
||||
|
||||
---
|
||||
## Документация
|
||||
|
||||
## 🔍 Продвинутые темы
|
||||
- [Учебные задания](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)
|
||||
|
||||
> Этот раздел не обязателен при первом прохождении стенда; к нему удобно вернуться, когда базовый CSV‑pipeline уже понятен.
|
||||
|
||||
### Архитектура pipeline
|
||||
|
||||
**Поток данных в DAG `csv_to_greenplum`:**
|
||||
1. `create_orders_table` — создаёт таблицу `public.orders` в Greenplum
|
||||
2. `generate_csv` — генерирует датасет при помощи pandas и сохраняет CSV в `CSV_DIR`
|
||||
3. `preview_csv` — выводит предпросмотр и статистику по данным
|
||||
4. `load_csv_to_greenplum` — загружает CSV во временную таблицу и переносит новые строки в `public.orders`
|
||||
|
||||
> 💡 **Безопасность повторного запуска:** Pipeline защищен от дубликатов, поэтому его можно запускать многократно.
|
||||
|
||||
### Проверка качества данных
|
||||
|
||||
Запустите DAG `csv_to_greenplum_dq` для автоматической проверки:
|
||||
- Наличие таблицы в базе
|
||||
- Соответствие схемы ожидаемой структуре
|
||||
- Объем загруженных данных
|
||||
- Отсутствие дубликатов записей
|
||||
|
||||
### Пример DAG с SQL-скриптами (bookings → stg)
|
||||
|
||||
> Если вы ещё не дошли до части про bookings и слои DWH, этот подраздел можно пропустить на первом чтении.
|
||||
|
||||
В репозитории есть учебный DAG `bookings_to_gp_stage`, который показывает «канонический» способ работы с SQL в Airflow:
|
||||
|
||||
- подключение к БД через Airflow Connections (`bookings_db`, `greenplum_conn`);
|
||||
- бизнес-логика инкрементальной загрузки и DQ вынесена в SQL-файлы в каталоге `sql/`:
|
||||
- `sql/src/bookings_generate_day_if_missing.sql` — генерация следующего учебного дня в демо-БД bookings (или нескольких стартовых дней, если база пуста);
|
||||
- `sql/stg/bookings_ddl.sql` — DDL для схемы `stg` и таблиц `stg.bookings_ext` / `stg.bookings`;
|
||||
- `sql/stg/bookings_load.sql` — загрузка инкремента из `stg.bookings_ext` в `stg.bookings` на основе «хвоста» после предыдущих батчей;
|
||||
- `sql/stg/bookings_dq.sql` — проверка количества строк между источником и stg за то же окно.
|
||||
|
||||
Фрагмент DAG:
|
||||
|
||||
```python
|
||||
load_bookings_to_stg = PostgresOperator(
|
||||
task_id="load_bookings_to_stg",
|
||||
postgres_conn_id="greenplum_conn",
|
||||
sql="stg/bookings_load.sql",
|
||||
params={"batch_id": "{{ ds_nodash }}"},
|
||||
)
|
||||
```
|
||||
|
||||
Такой подход помогает держать оркестрацию (DAG) и SQL-логику в отдельных файлах и легче сравнивать её с теорией из статьи про моделирование DWH.
|
||||
|
||||
### Ограничения учебного стенда
|
||||
|
||||
- **Greenplum** запущен в single-node режиме (для обучения)
|
||||
- В продакшене Greenplum обычно разворачивают кластером на нескольких серверах
|
||||
- Используется Greenplum 6 (широко доступная версия), хотя Greenplum 7 предлагает больше возможностей
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Подключение к базам через DBeaver
|
||||
|
||||
> Необязательный раздел: нужен только если вы хотите смотреть данные через DBeaver. Для базовых заданий достаточно `make gp-psql`.
|
||||
|
||||
Ниже — краткая инструкция, как подключиться к Greenplum и демо-БД bookings из DBeaver. Перед этим убедитесь, что стенд запущен:
|
||||
|
||||
- `cp .env.example .env` (если ещё не делали)
|
||||
- `make up`
|
||||
- `make bookings-init`
|
||||
- `make ddl-gp`
|
||||
|
||||
### Greenplum (аналитическая БД)
|
||||
|
||||
1. Откройте DBeaver → **New Database Connection**.
|
||||
2. Выберите драйвер **PostgreSQL** (или **Greenplum**, если он есть в вашей версии DBeaver).
|
||||
3. На вкладке **Main** заполните поля (по умолчанию):
|
||||
- `Host`: `localhost`
|
||||
- `Port`: `5435` (внешний порт Greenplum на хосте)
|
||||
- `Database`: значение `GP_DB` (по умолчанию `gp_dwh`)
|
||||
- `Username`: значение `GP_USER` (по умолчанию `gpadmin`)
|
||||
- `Password`: значение `GP_PASSWORD` (по умолчанию `gpadmin`)
|
||||
4. Нажмите **Test Connection** → **OK**, затем **Finish**.
|
||||
|
||||
После подключения:
|
||||
|
||||
- Основные таблицы лаба — в схеме `public` базы `gp_dwh` (например, `public.orders`).
|
||||
- После настройки PXF и выполнения `make ddl-gp` станет доступна внешняя таблица `public.ext_bookings_bookings` — чтение из демо-БД bookings через PXF.
|
||||
|
||||
### bookings-db (демо-БД источника)
|
||||
|
||||
Для работы с исходными данными (демо-БД `demo`) достаточно стандартного PostgreSQL-подключения.
|
||||
|
||||
1. Откройте DBeaver → **New Database Connection** → драйвер **PostgreSQL**.
|
||||
2. На вкладке **Main** укажите (значения по умолчанию из `.env.example`):
|
||||
- `Host`: `localhost`
|
||||
- `Port`: значение `BOOKINGS_DB_PORT` из `.env` (по умолчанию `5434`)
|
||||
- `Database`: `demo`
|
||||
- `Username`: `BOOKINGS_DB_USER` (по умолчанию `bookings`)
|
||||
- `Password`: `BOOKINGS_DB_PASSWORD` (по умолчанию `bookings`)
|
||||
3. Нажмите **Test Connection** → **OK**, затем **Finish**.
|
||||
|
||||
После подключения:
|
||||
|
||||
- Основные таблицы находятся в схеме `bookings` базы `demo` (например, `bookings.bookings`, `bookings.tickets`, `bookings.flights` и т.д.).
|
||||
- Можно сравнивать данные:
|
||||
- между `bookings.bookings` в Postgres и `public.ext_bookings_bookings` в Greenplum;
|
||||
- между временем (`book_date`) в UTC в `demo` и локальным временем в Greenplum (учитывая `TZ=Europe/Moscow`).
|
||||
|
||||
> Если вы меняли порты или креды в `.env`, не забудьте подставить те же значения в настройках соединений в DBeaver.
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Типичные проблемы и решения
|
||||
## Типичные проблемы и решения
|
||||
|
||||
| Проблема | Решение |
|
||||
|----------|---------|
|
||||
| Airflow UI не открывается | Дождитесь сообщения `Listening at: http://0.0.0.0:8080` в логах (`make logs`) |
|
||||
| `database "demo" does not exist` в bookings‑DAG | Вы сделали полный reset с удалением томов (`docker compose down -v` / `make clean`), поэтому демобаза bookings не установлена. Запустите `make bookings-init` и повторите DAG. |
|
||||
| Ошибка подключения к Greenplum | Убедитесь, что контейнер `greenplum` стал статусом `healthy` (проверьте `docker compose ps`) |
|
||||
| Не открывается порт 8080/5433/5434/5435 | Проверьте, что эти порты не заняты локальными сервисами; при необходимости остановите их или измените порты в `.env`/`docker-compose.yml` |
|
||||
| Нет файла в `./data` после запуска DAG | Проверьте логи задачи `generate_csv`, убедитесь, что `CSV_DIR` смонтирован в docker-compose |
|
||||
| Команда `make` не найдена | Используйте полные команды `docker compose` или установите make |
|
||||
| Greenplum не стартует/падает при старте | Попробуйте `make down && make up`. Если не помогло — полный reset: `make clean && make up` (удалит тома). |
|
||||
| `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` создаётся автоматически при старте контейнера. |
|
||||
| PXF не отвечает (Connection refused к порту 5888) | Проверьте `pxf cluster status` в контейнере `greenplum` и перезапустите сервис `greenplum`. |
|
||||
| PXF не подхватывает изменения конфигов | Пересоберите образ (`make build`) и перезапустите `greenplum`. Для принудительной перезаписи файлов задайте `PXF_SEED_OVERWRITE=1`. |
|
||||
| DAG `bookings_to_gp_stage` падает на внешней таблице/подключении к bookings | Убедитесь, что запущен контейнер `bookings-db` (`docker compose ps`, при необходимости `docker compose start bookings-db`), и выполнены `make bookings-init` и `make ddl-gp` или DAG `bookings_stg_ddl` |
|
||||
| DAG `bookings_to_gp_stage` ругается на отсутствующие таблицы stg | Запустите DAG `bookings_stg_ddl` (или выполните `make ddl-gp`), затем повторите запуск |
|
||||
| DAG не видит Greenplum/DEMObase по Airflow Connections | Убедитесь, что контейнеры `greenplum` и `bookings-db` запущены (`docker compose ps`). Подключения `greenplum_conn` и `bookings_db` задаются через переменные окружения `AIRFLOW_CONN_...` и могут не отображаться в UI, но `PostgresOperator` всё равно найдёт их по `conn_id`. При необходимости вы можете создать/отредактировать их вручную в разделе Connections. |
|
||||
|
||||
---
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
├── docker-compose.yml # Описание всех сервисов
|
||||
├── Dockerfile.airflow # Образ Airflow с зависимостями
|
||||
├── Dockerfile.greenplum # Образ Greenplum с интегрированным PXF
|
||||
├── .env.example # Шаблон настроек
|
||||
├── Makefile # Удобные команды для работы
|
||||
├── README.md # Обзор стенда
|
||||
├── TESTING.md # Пошаговый план проверки
|
||||
├── educational-tasks.md # Учебные задания для менти
|
||||
├── airflow/
|
||||
│ └── dags/ # Файлы workflow (DAG)
|
||||
│ ├── csv_to_greenplum.py
|
||||
│ ├── csv_to_greenplum_dq.py
|
||||
│ └── bookings_to_gp_stage.py
|
||||
├── bookings/ # Скрипты и файлы для демобазы bookings в Postgres
|
||||
├── sql/
|
||||
│ └── ddl_gp.sql # Общий DDL для Greenplum (подключает stg/src-скрипты)
|
||||
├── docs/ # Дополнительные документы (архитектура, bookings/STG, PXF)
|
||||
├── tests/ # Автоматические тесты (pytest)
|
||||
└── pxf/ # Конфигурация и файлы для PXF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Советы для дальнейшего обучения
|
||||
|
||||
1. **Поэкспериментируйте с DAG** — измените параметры генерации данных или размер батча
|
||||
2. **Добавьте свои проверки** — расширьте DAG `csv_to_greenplum_dq.py`
|
||||
3. **Попробуйте другие источники** — замените генератор данных на чтение из файла или API
|
||||
4. **Изучите Airflow deeper** — добавьте зависимости между задачами, настройте расписания
|
||||
|
||||
---
|
||||
|
||||
## ✅ Тестирование
|
||||
|
||||
- Локальные проверки: `make test` (pytest). Для форматирования — `make fmt`, для проверки — `make lint`.
|
||||
- Пошаговый сценарий с Docker (включая негативные кейсы и reset) — см. `TESTING.md`.
|
||||
- Полный smoke-тест стенда (сносит volumes!): `make e2e-smoke` — поднимает стек с нуля, прогоняет `csv_to_greenplum` и `bookings_to_gp_stage` через `airflow dags test` и проверяет, что в `public.orders` и `stg.bookings` появились строки.
|
||||
|
||||
|
||||
---
|
||||
| DAG `bookings_to_gp_stage` ругается на отсутствующие таблицы stg | Запустите `bookings_stg_ddl` (или выполните `make ddl-gp`), затем повторите запуск |
|
||||
| Порты 8080/5433/5434/5435 заняты | Остановите локальные сервисы или измените порты в `.env` |
|
||||
|
||||
## Благодарности
|
||||
|
||||
- **Postgres Pro** — за демо-БД bookings и генератор данных `demodb`: https://github.com/postgrespro/demodb (лицензия MIT: https://github.com/postgrespro/demodb/blob/main/LICENSE).
|
||||
- **Postgres Pro** — за демо‑БД bookings и генератор данных `demodb`: https://github.com/postgrespro/demodb (лицензия MIT: https://github.com/postgrespro/demodb/blob/main/LICENSE).
|
||||
- **woblerr** — за Docker-сборку Greenplum: https://github.com/woblerr/docker-greenplum (образ: `woblerr/greenplum`, лицензия MIT: https://github.com/woblerr/docker-greenplum/blob/master/LICENSE).
|
||||
|
||||
Удачи в изучении Data Engineering! 🚀
|
||||
+14
-22
@@ -20,6 +20,7 @@
|
||||
- `cp .env.example .env` (если файла ещё нет) и проверьте переменные:
|
||||
- `GP_PORT` — внутренний порт Greenplum в Docker-сети (по умолчанию 5432, менять не нужно); внешний порт для подключения с хоста фиксирован на `5435`, поэтому локальный PostgreSQL на 5432 не помешает.
|
||||
- `GP_USE_AIRFLOW_CONN=true` при желании использовать Airflow Connection; `false` — fallback на ENV.
|
||||
- Если меняли `airflow/requirements.txt` или `Dockerfile.airflow`, выполните `make build` перед `make up`.
|
||||
- `make up` — поднимаем все сервисы. Важно дождаться статуса `healthy` у `pgmeta` и `greenplum` (`docker compose ps`); `greenplum` считается `healthy` только когда поднят и Greenplum, и PXF.
|
||||
- `make logs` — следим, пока webserver и scheduler не перейдут в рабочее состояние (`Listening at: http://0.0.0.0:8080`).
|
||||
|
||||
@@ -27,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» режиме;
|
||||
@@ -53,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 (если «что-то сломалось»)
|
||||
@@ -76,13 +69,12 @@
|
||||
## 8. Снятие метрик и мониторинг
|
||||
- Контейнеры: `docker compose ps`, `docker stats` (по желанию).
|
||||
- Логи задач: в Airflow UI → конкретный таск → Log.
|
||||
- Хостовые CSV: каталог `data/` (можно открыть любой файл и убедиться в структуре).
|
||||
|
||||
## 9. Завершение работы
|
||||
- `make down` — выключает сервисы и удаляет тома (перезапишет данные в Greenplum!).
|
||||
- При необходимости сохранить данные: скопировать CSV из `data/` и дампы из контейнера до `make down`.
|
||||
- `make down` — выключает сервисы и удаляет контейнеры/сети (volumes сохраняются).
|
||||
- Полный сброс данных (удаляет volumes): `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.
|
||||
@@ -1,41 +1,141 @@
|
||||
# TODO (maintainers / mentors)
|
||||
|
||||
Этот файл собирает идеи по доработке стенда, которые не критичны для текущих задач менти,
|
||||
но улучшат стабильность и удобство сопровождения.
|
||||
Этот файл собирает задачи по подготовке стенда к курсовой работе
|
||||
и идеи по доработке, которые не критичны для текущих задач менти.
|
||||
|
||||
- Собрать свой образ Airflow поверх `apache/airflow:2.9.2`:
|
||||
Контекст и стратегия: [docs/design/PRD.md](docs/design/PRD.md).
|
||||
Дизайн задания: [docs/design/assignment_design.md](docs/design/assignment_design.md).
|
||||
|
||||
---
|
||||
|
||||
## Подготовка курсовой
|
||||
|
||||
> Дедлайн: ~2-3 недели (первый студент может подойти к курсовой).
|
||||
|
||||
### Этап 1. Вынос CSV-пайплайна
|
||||
|
||||
**Инструмент:** Sonnet / Gemini / ChatGPT — механическая работа, перенос файлов.
|
||||
|
||||
- [x] Перенести в [airflow-manual](https://github.com/dementev-dev/airflow-manual):
|
||||
`csv_to_greenplum.py`, `csv_to_greenplum_dq.py`, `ddl_greenplum_base.py`,
|
||||
`helpers/greenplum.py`, `sql/base/orders_ddl.sql`, связанные тесты
|
||||
- [x] Убрать CSV-зависимости из docker-compose / .env (`CSV_DIR`, `CSV_ROWS`)
|
||||
- [x] Обновить README (убрать упоминания CSV-пайплайна)
|
||||
|
||||
### Этап 1.5. Полировка эталона
|
||||
|
||||
**Инструмент:** Opus (глубокий анализ кода и контекста проекта)
|
||||
+ ручное тестирование (make up, запуск DAG'ов, проверка данных).
|
||||
|
||||
- [x] Протестировать полный ETL-цикл с нуля
|
||||
(make up → bookings-init (восстановление из дампа) → STG → ODS → DDS → DM)
|
||||
- [x] Прогнать инкремент (bookings-generate-day → повторный запуск DAG'ов)
|
||||
- [x] Почистить код эталонного среза
|
||||
- [x] Актуализировать README и документацию
|
||||
- [x] Убедиться, что `make test` и `make lint` проходят
|
||||
- [x] Проверить, что стенд поднимается на чистой машине
|
||||
(проверено 2026-03-09: `cp .env.example .env` → `make up` → `make bookings-init` →
|
||||
`make ddl-gp` → STG → ODS → DDS → DM — всё success)
|
||||
|
||||
### Этап 2. ТЗ от аналитика
|
||||
|
||||
**Инструмент:** Opus — нужно глубокое понимание предметной области, маппингов
|
||||
между слоями, SCD-паттернов и педагогического контекста.
|
||||
|
||||
> Делаем пока полный пайплайн работает — можно сверяться с реальными данными.
|
||||
|
||||
- [x] Создать `docs/assignment/analyst_spec.md`
|
||||
- [x] Для каждой таблицы-задания: имя, описание, поля, маппинг,
|
||||
бизнес-правила, тип SCD, гранулярность, distribution key
|
||||
- [x] Для dim_routes (SCD2): пошаговый алгоритм текстом, формула hashdiff
|
||||
- [x] Рекомендуемый порядок выполнения
|
||||
|
||||
### Этап 3. Валидационный DAG
|
||||
|
||||
**Инструмент:** Sonnet (шаблонная работа, структура в assignment_design.md)
|
||||
+ Opus для финальной вычитки.
|
||||
|
||||
> Делаем и тестируем на полных данных, пока ничего не удалено.
|
||||
|
||||
- [x] Создать `airflow/dags/bookings_validate.py`
|
||||
- [x] Создать SQL-скрипты в `sql/validate/`
|
||||
- [x] Таски по слоям: STG, ODS, DDS, DM
|
||||
- [x] Дружелюбные сообщения об ошибках с подсказками
|
||||
- [x] Протестировать на работающем стенде
|
||||
|
||||
### Этап 4. Подготовка main и ветка solution
|
||||
|
||||
**Инструмент:** Opus — раскладка по веткам, заглушки, ослабление DQ.
|
||||
|
||||
> Финальный этап: всё готово и протестировано, теперь раскладываем по веткам.
|
||||
> План: `docs/plans/2026-03-12_main-solution-split.md`
|
||||
|
||||
- [x] Смержить chore/bookings-etl → main
|
||||
- [x] Общие правки на main (документация, TODO.md)
|
||||
- [x] Создать ветку solution (снимок полного эталона)
|
||||
- [x] Main-only правки: заглушки, ослабление DQ, адаптация тестов
|
||||
- [x] Очистка docs на main (удалить внутренние документы)
|
||||
- [x] Верификация main (`make test`, `make lint`)
|
||||
- [x] Верификация solution (`make test`)
|
||||
|
||||
---
|
||||
|
||||
## Прочее (бэклог)
|
||||
|
||||
- [ ] Протестировать устойчивость `bookings-db` после остановки контейнеров:
|
||||
- прогнать сценарии `make stop` -> `make up` и `make down` -> `make up`;
|
||||
- зафиксировать, ломается ли генератор/данные в `bookings-db`;
|
||||
- при необходимости добавить шаги восстановления и обновить документацию.
|
||||
|
||||
---
|
||||
|
||||
## Выполнено
|
||||
|
||||
- [x] Сделать REST API Airflow основным способом тестирования ETL вместо CLI-вызовов
|
||||
через `docker compose exec ... airflow ...`:
|
||||
- обновить `TESTING.md`, сместив фокус на REST API сценарии;
|
||||
- оставить CLI как резервный вариант для локальной отладки;
|
||||
- проверить, что шаги тестирования воспроизводимы без входа в контейнер Airflow.
|
||||
|
||||
- [x] Собрать свой образ Airflow поверх `apache/airflow:2.9.2`:
|
||||
- вынести установку Python‑зависимостей из runtime (`pip install ...` при старте контейнеров)
|
||||
в отдельный `Dockerfile`;
|
||||
- переключить `docker-compose.yml` на использование этого образа для `airflow-webserver`,
|
||||
`airflow-scheduler` и `airflow-init`;
|
||||
- обновить документацию (README/TESTING) под новую схему сборки.
|
||||
|
||||
- Переключить Airflow с `SequentialExecutor` (SequentialScheduler) на `LocalExecutor`
|
||||
- [x] Переключить Airflow с `SequentialExecutor` (SequentialScheduler) на `LocalExecutor`
|
||||
для 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'а.
|
||||
|
||||
- Разобрать и стабилизировать интеграцию с Greenplum/PXF:
|
||||
- [x] Разобрать и стабилизировать интеграцию с Greenplum/PXF:
|
||||
- убедиться, что PXF в контейнере `greenplum` всегда корректно инициализируется
|
||||
(нет ошибок вида `protocol "pxf" does not exist` при первом запуске `make ddl-gp`);
|
||||
- при необходимости доработать init‑скрипты в `pxf/init/` и/или документацию,
|
||||
чтобы порядок действий для ментей был однозначным и воспроизводимым;
|
||||
- добавить краткий раздел в README/TESTING о типичных ошибках PXF/Greenplum и шагах по их устранению.
|
||||
- диагностика текущего кейса: `docs/internal/pxf_bookings.md` (раздел «Известная проблема»).
|
||||
- диагностика текущего кейса: `docs/reference/pxf_bookings.md` (раздел «Известная проблема»).
|
||||
|
||||
- Разобраться с генератором demodb:
|
||||
- после `make bookings-init` таблица `bookings.bookings` остаётся пустой;
|
||||
- [x] Разобраться с генератором demodb:
|
||||
- после `make bookings-generate` таблица `bookings.bookings` остаётся пустой;
|
||||
- патчи `bookings/patches/engine_jobs1_sync.patch` и `bookings/patches/install_drop_if_exists.patch`
|
||||
падают при применении (hunk failed / garbage in patch);
|
||||
- из‑за этого DAG `bookings_to_gp_stage` валится на проверках (источник пустой).
|
||||
- план: `plans/bookings-demodb-bugfix-plan.md`
|
||||
- детали: `docs/reference/bookings_db_issues.md`
|
||||
|
||||
- Добавить раздел «Благодарности» в `README.md`:
|
||||
- [x] Добавить раздел «Благодарности» в `README.md`:
|
||||
- явно поблагодарить Postgres Pro за демо‑БД bookings (репозиторий `postgrespro/demodb`);
|
||||
- указать автора Docker‑сборки Greenplum (`woblerr/docker-greenplum`, образ `woblerr/greenplum`);
|
||||
- при необходимости сослаться на соответствующие лицензии/README исходных проектов.
|
||||
|
||||
- Добавить в образ Airflow установку `psql`, чтобы тестировать загрузку CSV из CLI внутри контейнера (без root и дополнительных зависимостей на хосте).
|
||||
- [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`.
|
||||
Submodule
+1
Submodule _vendor/demodb added at d68de19285.
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -12,11 +12,31 @@ from __future__ import annotations
|
||||
Каждый запуск DAG работает как «шаг по времени вперёд»:
|
||||
- генератор в демо-БД bookings добавляет следующий учебный день после max(book_date);
|
||||
- загрузка в Greenplum берёт все строки, появившиеся после предыдущих батчей;
|
||||
- логическая дата запуска (`ds`) используется как удобная метка запуска (через `ds_nodash` в `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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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("✅ Дубликаты не обнаружены")
|
||||
+16
-6
@@ -4,28 +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` и данных не появляется.
|
||||
- Патчи применяются автоматически в `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
|
||||
@@ -36,6 +46,6 @@
|
||||
SELECT date_trunc('day', max(book_date)) + interval '1 day'
|
||||
INTO v_next_day
|
||||
FROM bookings.bookings;
|
||||
CALL continue(v_next_day); -- или CALL continue(v_next_day, 4)
|
||||
CALL continue(v_next_day, 1);
|
||||
END $$;
|
||||
```
|
||||
@@ -11,7 +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;
|
||||
|
||||
IF v_jobs < 1 THEN
|
||||
RAISE EXCEPTION 'bookings.jobs должен быть >= 1. Текущее значение: %.', v_jobs;
|
||||
END IF;
|
||||
|
||||
-- Ищем последнюю сгенерированную дату
|
||||
@@ -31,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()));
|
||||
|
||||
-- Если данных нет, останавливаемся с понятной ошибкой
|
||||
@@ -45,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;
|
||||
@@ -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';
|
||||
Binary file not shown.
+6
-9
@@ -1,6 +1,8 @@
|
||||
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}
|
||||
@@ -9,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
|
||||
@@ -32,7 +33,7 @@ services:
|
||||
POSTGRES_PASSWORD: ${PG_PASSWORD}
|
||||
POSTGRES_DB: ${PG_DB}
|
||||
ports:
|
||||
- "5433:5432"
|
||||
- "${PGMETA_PORT:-5433}:5432"
|
||||
volumes:
|
||||
- pgmeta:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -79,7 +80,7 @@ services:
|
||||
PXF_SYNC_ON_START: ${PXF_SYNC_ON_START:-0}
|
||||
# Порты: внешний 5435 (на хосте)
|
||||
ports:
|
||||
- "5435:5432"
|
||||
- "${GP_HOST_PORT:-5435}:5432"
|
||||
volumes:
|
||||
- ./sql:/sql:ro
|
||||
- greenplum_data:/data
|
||||
@@ -104,11 +105,10 @@ services:
|
||||
<<: *airflow-env
|
||||
command: bash -lc "rm -f /opt/airflow/airflow-webserver.pid && exec airflow webserver"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "${AIRFLOW_WEB_PORT:-8080}:8080"
|
||||
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:
|
||||
@@ -133,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:
|
||||
@@ -152,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;
|
||||
@@ -173,5 +171,4 @@ volumes:
|
||||
pgmeta:
|
||||
bookings_data:
|
||||
greenplum_data:
|
||||
airflow_data:
|
||||
airflow_logs:
|
||||
@@ -0,0 +1,46 @@
|
||||
# Документация
|
||||
|
||||
Этот каталог содержит дополнительные материалы к учебному стенду.
|
||||
|
||||
## Быстрый путь (для менти)
|
||||
|
||||
- [Быстрый старт и команды](../README.md)
|
||||
- [Учебные задания](assignment/README.md)
|
||||
- [План тестирования и проверки](../TESTING.md)
|
||||
- [Главный учебный DAG: bookings → stg](bookings_to_gp_stage.md)
|
||||
- [Учебный DAG: stg -> ods](bookings_to_gp_ods.md)
|
||||
- [Учебный DAG: ods -> dds](bookings_to_gp_dds.md)
|
||||
- [Учебный DAG: dds -> dm](bookings_to_gp_dm.md)
|
||||
|
||||
## Дизайн (`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)
|
||||
- [Развитие лабораторных: аудитория, решения и критерии заданий](design/labs_development.md)
|
||||
|
||||
## Справочники (`reference/`)
|
||||
|
||||
- [Как устроен Docker-стенд (образы, Connections, переменные окружения)](stack.md)
|
||||
- [PXF в этом проекте (проектная реализация)](reference/pxf_bookings.md)
|
||||
- [Про время/UTC в bookings](reference/bookings_tz.md)
|
||||
- [Известные проблемы bookings-db](reference/bookings_db_issues.md)
|
||||
- [Бенчмарк генерации данных](reference/bookings_generation_benchmark.md)
|
||||
- [QA-план отладки пайплайна](reference/qa-plan.md)
|
||||
- [Порядок запуска DAG-ов](dag_execution_order.md)
|
||||
- [End-to-end протокол тестирования](e2e-etl-test-protocol.md)
|
||||
- [Тестирование DAG-ов через API](agent-dag-testing.md)
|
||||
|
||||
## Планы (`plans/`)
|
||||
|
||||
Активные планы работ. После выполнения переносятся в `archive/`.
|
||||
|
||||
## Архив (`archive/`)
|
||||
|
||||
Выполненные планы, закрытые ревью. Ссылки внутри файлов могут быть устаревшими.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Тестирование DAG (гайд для AI-агентов)
|
||||
|
||||
Этот документ описывает, как агенту взаимодействовать с Airflow (без UI) для точечной проверки и отладки DAG'ов в процессе разработки.
|
||||
|
||||
## 0. Состояние среды (Предварительная проверка)
|
||||
|
||||
Прежде чем тестировать DAG, убедитесь, что стек работает:
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
Если контейнеров нет, поднимите стек: `make up`.
|
||||
Если исходная база данных пуста (например, после `make clean`), проинициализируйте её: `make bookings-init`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Быстрая проверка структуры графа (Локально)
|
||||
|
||||
При любом изменении Python-кода DAG'а сначала проверьте, что он компилируется и структура графа корректна:
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
Это запустит smoke-тесты (`tests/test_dags_smoke.py`), которые проверят целостность всех DAG'ов без обращения к базе данных.
|
||||
|
||||
---
|
||||
|
||||
## 2. Запуск конкретного DAG'а (REST API)
|
||||
|
||||
Для тестирования загрузки данных запустите измененный DAG через REST API (базовый URL `http://localhost:8080/api/v1`, креды взять из `.venv`).
|
||||
|
||||
**Шаг 2.1. Снять DAG с паузы (при старте стенда все DAG'и на паузе — этот шаг обязателен!):**
|
||||
```bash
|
||||
curl -s -X PATCH "http://localhost:8080/api/v1/dags/<dag_id>" \
|
||||
-u admin:admin -H "Content-Type: application/json" -d '{"is_paused": false}'
|
||||
```
|
||||
|
||||
**Шаг 2.2. Запустить DAG:**
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:8080/api/v1/dags/<dag_id>/dagRuns" \
|
||||
-u admin:admin -H "Content-Type: application/json" -d '{}' | jq '{dag_run_id}'
|
||||
```
|
||||
|
||||
**Шаг 2.3. Проверить статус выполнения:**
|
||||
Используйте `dag_run_id` из предыдущего шага:
|
||||
```bash
|
||||
curl -s "http://localhost:8080/api/v1/dags/<dag_id>/dagRuns/<dag_run_id>" \
|
||||
-u admin:admin | jq '{state}'
|
||||
```
|
||||
Повторяйте запрос, пока `state` не станет `success` или `failed`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Отладка упавших задач
|
||||
|
||||
Если DAG перешел в статус `failed`, найдите упавшую задачу:
|
||||
|
||||
**Шаг 3.1. Получить статусы всех задач:**
|
||||
```bash
|
||||
curl -s "http://localhost:8080/api/v1/dags/<dag_id>/dagRuns/<dag_run_id>/taskInstances" \
|
||||
-u admin:admin | jq '.task_instances[] | {task_id, state}'
|
||||
```
|
||||
|
||||
**Шаг 3.2. Посмотреть логи упавшей задачи (через CLI Airflow):**
|
||||
REST API отдает логи сложно, поэтому для логов проще использовать `docker compose exec`:
|
||||
```bash
|
||||
docker compose exec airflow-webserver airflow tasks logs <dag_id> <task_id> <run_id>
|
||||
```
|
||||
|
||||
*(Совет: ищите в логах слова `ERROR`, `Exception` или вывод SQL-ошибок от PostgresOperator).*
|
||||
|
||||
---
|
||||
|
||||
## 4. Проверка результата в DWH (Greenplum)
|
||||
|
||||
Успешное выполнение DAG'а (зеленый статус) не гарантирует, что данные загрузились правильно (например, если источник был пуст). Проверьте целевые таблицы напрямую:
|
||||
|
||||
```bash
|
||||
docker compose exec greenplum bash -lc "su - gpadmin -c \"/usr/local/greenplum-db/bin/psql -t -A -d gp_dwh -c 'SELECT COUNT(*) FROM <схема>.<таблица>;'\""
|
||||
```
|
||||
Убедитесь, что таблица содержит ожидаемое количество строк.
|
||||
|
||||
---
|
||||
|
||||
## 5. Полный сквозной тест (E2E)
|
||||
|
||||
Если вы вносили масштабные изменения, затрагивающие несколько слоев DWH, или меняли DDL таблиц, рекомендуется прогнать полный конвейер (STG -> ODS -> DDS -> DM):
|
||||
```bash
|
||||
make e2e-etl
|
||||
```
|
||||
Этот скрипт сам запустит все нужные DAG'и в правильном порядке и проверит результаты.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Ревью решения (образец для студентов): `bookings-db` → `stg` в Greenplum
|
||||
|
||||
Этот документ фиксирует рекомендации по улучшению учебного решения ETL (Airflow + Greenplum + PXF)
|
||||
на основе ревью изменений ветки `chore/bookings-etl` (добавление полного STG слоя и пайплайна загрузки).
|
||||
|
||||
Цель ревью — сделать решение **безоговорочно рекомендуемым** к изучению начинающими:
|
||||
понятным, предсказуемым, с корректной терминологией и честными инженерными компромиссами.
|
||||
|
||||
---
|
||||
|
||||
## 1) Сильные стороны решения (что уже хорошо и стоит сохранить)
|
||||
|
||||
1. **Единый “шаблон” по таблицам** в `sql/stg/`:
|
||||
- `{table}_ddl.sql` — создаёт `*_ext` и внутреннюю таблицу;
|
||||
- `{table}_load.sql` — загружает данные;
|
||||
- `{table}_dq.sql` — валидирует качество и останавливает пайплайн при проблемах.
|
||||
|
||||
Это отличная учебная структура: студент быстро понимает, “где что лежит” и как добавлять новые таблицы.
|
||||
|
||||
2. **DAG как оркестратор, SQL как логика**:
|
||||
- `airflow/dags/bookings_to_gp_stage.py` и `airflow/dags/bookings_stg_ddl.py` используют `PostgresOperator`
|
||||
и читают SQL с диска через `template_searchpath="/sql"`.
|
||||
Это соответствует “канонической” модели: Airflow управляет шагами, а трансформации живут в SQL.
|
||||
|
||||
3. **Понятные сообщения при падении DQ** (в большинстве скриптов): студенту легче дебажить.
|
||||
|
||||
---
|
||||
|
||||
## 2) Критичные замечания (статус на текущий момент)
|
||||
|
||||
### 2.1. Некорректные утверждения про MPP и co-location (статус: исправлено)
|
||||
|
||||
В Greenplum производительность JOIN сильно зависит от распределения данных по сегментам.
|
||||
Если ключ распределения двух таблиц совпадает с ключом JOIN — часто удаётся обойтись без перераспределения данных (motion).
|
||||
|
||||
Раньше в некоторых DDL-комментариях обещалась co-location там, где её не будет.
|
||||
Это педагогически опасно: студент запоминает неверную модель, а потом “не понимает”, почему запросы медленные.
|
||||
|
||||
Что сделано:
|
||||
- DDL-комментарии приведены к честной формулировке “ключ выбран так-то, но JOIN по другим ключам может требовать motion”.
|
||||
- Исправлены места, где co-location заявлялась ошибочно (в т.ч. `routes`, `flights`, `segments`, `boarding_passes`).
|
||||
|
||||
Файлы: `sql/stg/routes_ddl.sql`, `sql/stg/flights_ddl.sql`, `sql/stg/segments_ddl.sql`, `sql/stg/boarding_passes_ddl.sql`.
|
||||
|
||||
### 2.2. DQ-проверки ссылочной целостности: “текущий батч” vs “вся история” (статус: исправлено)
|
||||
|
||||
Часть DQ-скриптов проверяет наличие “родительских” записей в таблице **без фильтра `_load_id`**.
|
||||
При append-only истории это может скрыть проблемы текущей загрузки:
|
||||
родитель был загружен в прошлом батче → проверка пройдёт, даже если текущий батч родителя не загрузил.
|
||||
|
||||
Что сделано:
|
||||
- `routes_dq.sql`: проверка airports/airplanes стала батч-строгой (`_load_id = текущий батч`).
|
||||
- `seats_dq.sql`: проверка airplanes стала батч-строгой (`_load_id = текущий батч`).
|
||||
- `flights_dq.sql`: проверка routes стала батч-строгой (`_load_id = текущий батч`).
|
||||
|
||||
Примечание (почему не везде `_load_id = текущий батч`):
|
||||
- Если дочерняя таблица грузится инкрементом, то ссылки могут указывать на “исторические” записи,
|
||||
загруженные в предыдущих батчах → для таких связей корректнее проверять “существует в STG вообще”.
|
||||
- Для `boarding_passes` (full snapshot) ссылки на `tickets/segments` также проверяются по STG-истории,
|
||||
потому что `tickets/segments` не перезагружаются полным снэпшотом каждый запуск.
|
||||
|
||||
### 2.3. Smoke-тесты DAG’ов (статус: исправлено)
|
||||
|
||||
Что сделано:
|
||||
- Тесты усилены: теперь проверяются ключевые зависимости графа через `get_direct_relatives(upstream=False)` и “барьеры” через `get_flat_relatives(upstream=False)`.
|
||||
|
||||
### 2.4. Документация по DAG (статус: синхронизировано)
|
||||
|
||||
Что сделано:
|
||||
- `docs/bookings_to_gp_stage.md` обновлён так, чтобы отражать текущий набор таблиц и шагов пайплайна.
|
||||
|
||||
---
|
||||
|
||||
## 3) Рекомендации по качеству и читаемости (Clean Code для SQL и DAG)
|
||||
|
||||
### 3.1. “Empty window” в инкременте: договориться о политике (fail vs skip) (статус: исправлено)
|
||||
|
||||
Раньше поведение было разным:
|
||||
- часть DQ-скриптов падала, если в окне инкремента 0 строк;
|
||||
- `boarding_passes_dq.sql` делал `RAISE NOTICE` и `RETURN`.
|
||||
|
||||
Обе стратегии допустимы, но в учебном решении важно выбрать одну и объяснить:
|
||||
- **Fail** полезен, когда “ожидаем данные в каждом запуске” (например, учебный генератор должен добавлять день);
|
||||
- **Skip** полезен, когда “окно может быть пустым и это нормально”.
|
||||
|
||||
Выбранная политика для учебного стенда:
|
||||
- Для инкрементальных таблиц (`bookings`, `tickets`, `flights`, `segments`) “пустое окно” **допустимо**:
|
||||
DQ логирует `NOTICE` и завершает проверку, не падая.
|
||||
- Для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) пустой источник считаем ошибкой:
|
||||
это почти всегда признак проблем с PXF/источником.
|
||||
|
||||
### 3.2. Комментарии в `*_load.sql`: точнее формулировать “идемпотентность”, а не “дедупликацию источника” (статус: исправлено)
|
||||
|
||||
Типовой паттерн:
|
||||
```sql
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM stg.table WHERE _load_id = '{{ run_id }}' AND key = ext.key
|
||||
);
|
||||
```
|
||||
|
||||
Это в первую очередь защита от повторного запуска того же таска в рамках одного `_load_id` (retry),
|
||||
а не “лечение” дублей в источнике.
|
||||
|
||||
Что сделано:
|
||||
- В `sql/stg/*_load.sql` комментарии приведены к формулировке “идемпотентность при повторном запуске/ретрае”.
|
||||
|
||||
### 3.3. Проверка составных ключей: избегать склейки строк (статус: исправлено)
|
||||
|
||||
Паттерн вида `COUNT(DISTINCT col1 || '|' || col2)` теоретически может давать коллизии (если в данных встречается разделитель).
|
||||
В учебном стенде риск небольшой, но как “эталон” лучше показывать более безопасный подход.
|
||||
|
||||
Что сделано:
|
||||
- Заменили склейку строк на `COUNT(DISTINCT md5(ROW(col1, col2)::text))` в DQ‑скриптах для составных ключей.
|
||||
Такой подход сохраняет DV‑стиль и убирает неоднозначность разделителей.
|
||||
|
||||
---
|
||||
|
||||
## 4) Практические примеры “как улучшить”
|
||||
|
||||
### 4.1. Батч-строгая ссылочная целостность (пример подхода)
|
||||
|
||||
Если таблицы грузятся как snapshot в рамках батча, проверки можно сделать батч-строгими:
|
||||
“в текущем батче ссылки указывают на строки текущего батча”.
|
||||
|
||||
Идея (пример для routes → airports):
|
||||
```sql
|
||||
LEFT JOIN stg.airports AS a
|
||||
ON r.departure_airport = a.airport_code
|
||||
AND a._load_id = v_batch_id
|
||||
```
|
||||
|
||||
### 4.2. Smoke-тест реального графа (минимальный полезный уровень)
|
||||
|
||||
Вместо “таски существуют” лучше проверять ключевые зависимости:
|
||||
```python
|
||||
tickets_dq = dag.get_task("check_tickets_dq")
|
||||
airports_load = dag.get_task("load_airports_to_stg")
|
||||
assert airports_load in tickets_dq.get_direct_relatives(upstream=False)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) Чек-лист “готово как эталон”
|
||||
|
||||
- [x] В DDL-комментариях нет неверных обещаний про co-location/уникальность ключей.
|
||||
- [x] Для DQ определена и описана политика “0 строк”: где fail, где skip.
|
||||
- [x] DQ ссылочной целостности не маскирует проблемы текущего батча (batch-строгие проверки там, где это уместно).
|
||||
- [x] `docs/bookings_to_gp_stage.md` соответствует фактическому DAG.
|
||||
- [x] Smoke-тесты проверяют хотя бы критические зависимости графа.
|
||||
@@ -0,0 +1,205 @@
|
||||
# План: Денормализация dim_routes + упрощение route_performance
|
||||
|
||||
> **Статус:** реализовано.
|
||||
|
||||
## Контекст
|
||||
|
||||
`dds.dim_routes` хранит только FK-ссылки (`departure_airport`, `arrival_airport`, `airplane_code`) без атрибутов (город, модель самолёта, кол-во мест). Из-за этого витрина `dm.route_performance` вынуждена делать 4-JOIN цепочку вместо одного JOIN к измерению. Это противоречит принципу Кимбалла (измерение должно быть «самодостаточным») и усложняет учебный материал.
|
||||
|
||||
**Цель:** добавить в dim_routes 4 денормализованных колонки, упростить route_performance до 1 JOIN, показать студентам правильную Star Schema.
|
||||
|
||||
---
|
||||
|
||||
## Файлы для изменения
|
||||
|
||||
| # | Файл | Суть |
|
||||
|---|---|---|
|
||||
| 1 | `sql/dds/dim_routes_ddl.sql` | +4 колонки через ALTER TABLE |
|
||||
| 2 | `sql/dds/dim_routes_load.sql` | JOIN к airports/airplanes при INSERT + refresh-шаг |
|
||||
| 3 | `sql/dds/dim_routes_dq.sql` | NOT NULL проверка для новых колонок (current-срез) |
|
||||
| 4 | `airflow/dags/bookings_to_gp_dds.py` | Зависимость: airports+airplanes DQ → routes load |
|
||||
| 5 | `sql/dm/route_performance_load.sql` | Упрощение 4-JOIN → 1-JOIN |
|
||||
| 6 | `tests/test_dags_smoke.py` | Новые assert для зависимости routes от airports/airplanes |
|
||||
| 7 | `docs/internal/db_schema.md` | Обновить схему dim_routes |
|
||||
|
||||
---
|
||||
|
||||
## Шаг 1. DDL — `sql/dds/dim_routes_ddl.sql`
|
||||
|
||||
После существующего `COMMENT ON TABLE` добавить учебный комментарий и 4 ALTER TABLE:
|
||||
|
||||
```sql
|
||||
-- Денормализация атрибутов из SCD1-измерений (dim_airports, dim_airplanes).
|
||||
--
|
||||
-- Учебный комментарий (Kimball Star Schema):
|
||||
-- Измерение должно быть «самодостаточным»: один JOIN к dim_routes —
|
||||
-- и аналитик видит маршрут, города, модель самолёта и кол-во мест.
|
||||
--
|
||||
-- Эти колонки НЕ участвуют в hashdiff. Версия SCD2 фиксирует изменения
|
||||
-- атрибутов маршрута (аэропорт, самолёт, расписание). Если изменится
|
||||
-- название города — обновим отдельным refresh-шагом, не создавая новую версию.
|
||||
ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS departure_city TEXT;
|
||||
ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS arrival_city TEXT;
|
||||
ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS airplane_model TEXT;
|
||||
ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS total_seats INTEGER;
|
||||
```
|
||||
|
||||
Колонки nullable — `ALTER TABLE ADD COLUMN ... NOT NULL` без DEFAULT упадёт на существующих строках. DQ проверит NOT NULL для current-среза.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2. Load — `sql/dds/dim_routes_load.sql`
|
||||
|
||||
Структура: 3 фазы вместо 2.
|
||||
|
||||
**Фаза 1 (без изменений):** temp table + hashdiff из ods.routes, UPDATE закрытие версий. Hashdiff **не включает** денормализованные атрибуты.
|
||||
|
||||
**Фаза 2 (изменён INSERT):** при вставке новой версии — LEFT JOIN к dim_airports (×2) и dim_airplanes для заполнения departure_city, arrival_city, airplane_model, total_seats.
|
||||
|
||||
Конкретно: в INSERT (Statement 2, строки 59-107) добавить:
|
||||
- В список колонок: `departure_city, arrival_city, airplane_model, total_seats`
|
||||
- В SELECT: `dep.city, arr.city, air.model, air.total_seats`
|
||||
- LEFT JOIN к dim_airports и dim_airplanes (LEFT — чтобы не терять маршруты при отсутствии аэропорта; DQ поймает)
|
||||
|
||||
**Фаза 3 (новая):** refresh денормализованных атрибутов для всех current-версий:
|
||||
|
||||
```sql
|
||||
-- Фаза 3: Обновление денормализованных атрибутов (refresh).
|
||||
-- Нужна для SCD1-изменений в dim_airports/dim_airplanes (напр. переименование города).
|
||||
-- Обновляем ТОЛЬКО current-версии (valid_to IS NULL).
|
||||
UPDATE dds.dim_routes AS d
|
||||
SET departure_city = dep.city,
|
||||
arrival_city = arr.city,
|
||||
airplane_model = air.model,
|
||||
total_seats = air.total_seats,
|
||||
updated_at = now(),
|
||||
_load_id = '{{ run_id }}',
|
||||
_load_ts = now()
|
||||
FROM dds.dim_airports AS dep,
|
||||
dds.dim_airports AS arr,
|
||||
dds.dim_airplanes AS air
|
||||
WHERE d.valid_to IS NULL
|
||||
AND dep.airport_bk = d.departure_airport
|
||||
AND arr.airport_bk = d.arrival_airport
|
||||
AND air.airplane_bk = d.airplane_code
|
||||
AND (
|
||||
d.departure_city IS DISTINCT FROM dep.city
|
||||
OR d.arrival_city IS DISTINCT FROM arr.city
|
||||
OR d.airplane_model IS DISTINCT FROM air.model
|
||||
OR d.total_seats IS DISTINCT FROM air.total_seats
|
||||
);
|
||||
```
|
||||
|
||||
Этот же шаг при первом запуске заполнит колонки для существующих данных (backfill).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 3. DQ — `sql/dds/dim_routes_dq.sql`
|
||||
|
||||
Перед финальным `RAISE NOTICE` добавить проверку:
|
||||
|
||||
```sql
|
||||
-- Денормализованные поля не пустые в текущих (актуальных) версиях.
|
||||
-- Исторические версии могли быть загружены ДО добавления колонок — пропускаем.
|
||||
SELECT COUNT(*) INTO v_null_count
|
||||
FROM dds.dim_routes
|
||||
WHERE valid_to IS NULL
|
||||
AND (departure_city IS NULL OR arrival_city IS NULL
|
||||
OR airplane_model IS NULL OR total_seats IS NULL);
|
||||
|
||||
IF v_null_count <> 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'DQ FAILED: в current-срезе dim_routes найдены NULL денормализованные поля: %',
|
||||
v_null_count;
|
||||
END IF;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 4. DAG — `airflow/dags/bookings_to_gp_dds.py`
|
||||
|
||||
Убрать `load_dds_dim_routes` из параллельного fan-out, добавить зависимость от DQ airports и airplanes:
|
||||
|
||||
```python
|
||||
# Было:
|
||||
dq_dds_dim_calendar >> [
|
||||
load_dds_dim_airports,
|
||||
load_dds_dim_airplanes,
|
||||
load_dds_dim_tariffs,
|
||||
load_dds_dim_passengers,
|
||||
load_dds_dim_routes, # параллельно
|
||||
]
|
||||
|
||||
# Стало:
|
||||
dq_dds_dim_calendar >> [
|
||||
load_dds_dim_airports,
|
||||
load_dds_dim_airplanes,
|
||||
load_dds_dim_tariffs,
|
||||
load_dds_dim_passengers,
|
||||
]
|
||||
|
||||
# dim_routes зависит от airports и airplanes (денормализация).
|
||||
[dq_dds_dim_airports, dq_dds_dim_airplanes] >> load_dds_dim_routes
|
||||
```
|
||||
|
||||
Транзитивная зависимость от calendar сохраняется через airports/airplanes.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 5. Витрина — `sql/dm/route_performance_load.sql`
|
||||
|
||||
Упростить Шаг 3. Было 4 JOIN → станет 1 JOIN:
|
||||
|
||||
```sql
|
||||
FROM tmp_route_metrics m
|
||||
JOIN dds.dim_routes r_curr
|
||||
ON m.route_bk = r_curr.route_bk AND r_curr.valid_to IS NULL
|
||||
```
|
||||
|
||||
SELECT использует `r_curr.departure_airport AS departure_airport_bk`, `r_curr.departure_city`, `r_curr.airplane_code AS airplane_bk`, `r_curr.airplane_model`, `r_curr.total_seats`. Добавить учебный комментарий: «Благодаря денормализации dim_routes — один JOIN вместо четырёх.»
|
||||
|
||||
---
|
||||
|
||||
## Шаг 6. Тесты — `tests/test_dags_smoke.py`
|
||||
|
||||
Добавить в `test_bookings_to_gp_dds_dag_structure`:
|
||||
|
||||
```python
|
||||
# dim_routes зависит от airports и airplanes (денормализация).
|
||||
_assert_reachable(dag, "dq_dds_dim_airports", "load_dds_dim_routes")
|
||||
_assert_reachable(dag, "dq_dds_dim_airplanes", "load_dds_dim_routes")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 7. Документация — `docs/internal/db_schema.md`
|
||||
|
||||
Обновить описание dim_routes: добавить 4 новые колонки, пометить «денормализовано из dim_airports/dim_airplanes». Обновить граф зависимостей DAG.
|
||||
|
||||
---
|
||||
|
||||
## Что НЕ меняется
|
||||
|
||||
- **fact_flight_sales_load.sql** — факт резолвит SK через свои JOIN-ы. Без изменений.
|
||||
- **passenger_loyalty, monthly_overview** — используют только `route_bk` из dim_routes.
|
||||
- **airport_traffic** — вообще не джойнит dim_routes.
|
||||
- **hashdiff** — остаётся прежним (только атрибуты из ods.routes).
|
||||
|
||||
---
|
||||
|
||||
## Верификация
|
||||
|
||||
1. `make ddl-gp` — накатить DDL (ALTER TABLE добавит колонки)
|
||||
2. Запустить DAG `bookings_to_gp_dds` — проверить:
|
||||
- airports/airplanes грузятся ДО routes (Graph view)
|
||||
- Фаза 3 (refresh) заполняет departure_city, arrival_city, airplane_model, total_seats
|
||||
- DQ проходит без ошибок
|
||||
3. Запустить DAG `bookings_to_gp_dm` — проверить:
|
||||
- route_performance загружается с 1 JOIN
|
||||
- Витрина содержит корректные города и модели самолётов
|
||||
4. `make test` — smoke-тесты DAG проходят (включая новые assert'ы)
|
||||
5. SQL-проверка:
|
||||
```sql
|
||||
SELECT route_bk, departure_city, arrival_city, airplane_model, total_seats
|
||||
FROM dds.dim_routes WHERE valid_to IS NULL LIMIT 5;
|
||||
```
|
||||
@@ -0,0 +1,220 @@
|
||||
# План ревизии документации
|
||||
|
||||
> Статус: **ЧЕРНОВИК v3** | Дата: 2026-03-10
|
||||
> Контекст: перед Этапом 2 (подготовка main) нужно навести порядок в docs/
|
||||
|
||||
---
|
||||
|
||||
## Проблемы
|
||||
|
||||
- `docs/internal/` — свалка: дизайн-документы, планы, ревью, баг-трекеры, стандарты
|
||||
- 3 архивных плана лежат рядом с живыми документами (неотличимы)
|
||||
- 3 осиротевших документа (никто не ссылается)
|
||||
- `educational-tasks.md` в корне — устарел (раздел 2.2 говорит «ODS/DDS/DM будут позже»)
|
||||
- 5 документов содержат устаревшие фрагменты
|
||||
- `docs/README.md` не знает про несколько живых документов
|
||||
|
||||
---
|
||||
|
||||
## Принципы
|
||||
|
||||
- **Архив замораживается.** Файлы в `docs/archive/` не правим — ссылки внутри них
|
||||
могут быть битыми, это ожидаемо. Они сохраняются как исторические артефакты.
|
||||
- **Активные планы** живут в `docs/plans/`, после выполнения переезжают в `docs/archive/`.
|
||||
- **Студенческий entry point** не должен исчезать: пока `analyst_spec.md` (Этап 3)
|
||||
не создан, в `docs/assignment/` будет заглушка `README.md` со ссылкой на эталонный
|
||||
срез для самостоятельного изучения.
|
||||
|
||||
---
|
||||
|
||||
## Фаза 1. Структура каталогов
|
||||
|
||||
Создать новые каталоги:
|
||||
|
||||
```
|
||||
docs/design/ — дизайн-документы, стандарты, архитектура
|
||||
docs/reference/ — техническая справка, баг-трекеры, бенчмарки
|
||||
docs/plans/ — активные планы работ
|
||||
docs/archive/ — выполненные планы, закрытые ревью
|
||||
docs/assignment/ — заглушка README.md (подготовка для Этапа 3)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Фаза 2. Перемещение файлов
|
||||
|
||||
### В `docs/archive/` (4 файла)
|
||||
|
||||
| Откуда | Файл | Причина |
|
||||
|--------|-------|---------|
|
||||
| корень | `educational-tasks.md` | Устарел, заменён `assignment_design.md` |
|
||||
| `docs/internal/` | `stg_naming_unification_plan.md` | Выполнен |
|
||||
| `docs/internal/` | `dim_routes_denormalization_plan.md` | Выполнен |
|
||||
| `docs/internal/` | `bookings_stg_code_review.md` | Все замечания закрыты |
|
||||
|
||||
### В `docs/plans/` (1 файл)
|
||||
|
||||
| Откуда | Файл | Примечание |
|
||||
|--------|-------|---------|
|
||||
| `docs/internal/` | `docs_restructuring_plan.md` | Этот план — активный; после выполнения → `docs/archive/` |
|
||||
|
||||
### В `docs/design/` (9 файлов из `docs/internal/`)
|
||||
|
||||
| Файл | Роль |
|
||||
|------|------|
|
||||
| `PRD.md` | Стратегия продукта |
|
||||
| `assignment_design.md` | Дизайн курсового задания |
|
||||
| `naming_conventions.md` | Стандарт нейминга (единый источник) |
|
||||
| `db_schema.md` | Схема БД всех слоёв DWH |
|
||||
| `bookings_stg_design.md` | Дизайн STG-слоя |
|
||||
| `bookings_ods_design.md` | Дизайн ODS-слоя |
|
||||
| `bookings_dds_design.md` | Дизайн DDS-слоя |
|
||||
| `bookings_dm_design.md` | Дизайн DM-слоя |
|
||||
| `architecture_review.md` | Архитектурные решения (ADR) |
|
||||
|
||||
### В `docs/reference/` (5 файлов из `docs/internal/`)
|
||||
|
||||
| Файл | Роль |
|
||||
|------|------|
|
||||
| `pxf_bookings.md` | PXF: настройка, проблемы |
|
||||
| `bookings_tz.md` | Источник bookings-db |
|
||||
| `bookings_db_issues.md` | Известные проблемы bookings-db |
|
||||
| `bookings_generation_benchmark.md` | Бенчмарк генерации |
|
||||
| `qa-plan.md` | План отладки пайплайна |
|
||||
|
||||
**Итог:** `docs/internal/` опустеет → удалить.
|
||||
|
||||
---
|
||||
|
||||
## Фаза 3. Обновление перекрёстных ссылок
|
||||
|
||||
### Категория A. Корневые и публичные файлы (ссылаются на `docs/internal/`)
|
||||
|
||||
| Файл | Ссылок | Детали замен |
|
||||
|------|--------|------|
|
||||
| `AGENTS.md` | 2 | строки 15, 45: `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
|
||||
| `TODO.md` | 6 | строки 6, 7, 43: → `docs/design/`; строка 121: → `docs/reference/pxf_bookings.md`; строка 128: → `docs/reference/bookings_db_issues.md`; строка 141: → `docs/archive/dim_routes_denormalization_plan.md` |
|
||||
| `docs/README.md` | 7 | строки 18-24: все `internal/*` → `design/*` или `reference/*` |
|
||||
| `docs/bookings_to_gp_stage.md` | 2 | строка 151: → `reference/pxf_bookings.md`; строка 156: → `archive/bookings_stg_code_review.md` |
|
||||
| `docs/stack.md` | 1 | строка 85: → `reference/pxf_bookings.md` |
|
||||
|
||||
### Категория B. Файлы внутри `docs/design/` и `docs/reference/`
|
||||
|
||||
Полный реестр ссылок с `docs/internal/` в файлах, которые переедут в `design/` или
|
||||
`reference/`. Все требуют обновления — либо кросс-каталожные пути, либо display-тексты.
|
||||
|
||||
**B1. Кросс-каталожные ссылки (ссылка ведёт в другой каталог — путь сломается):**
|
||||
|
||||
| Файл (→ design/) | Строка | Ссылка | Новый путь |
|
||||
|------|--------|--------|------|
|
||||
| `db_schema.md` | 426 | `bookings_tz.md` (отн.) | `../reference/bookings_tz.md` |
|
||||
| `db_schema.md` | 427 | `pxf_bookings.md` (отн.) | `../reference/pxf_bookings.md` |
|
||||
| `db_schema.md` | 425 | `bookings_stg_code_review.md` (отн.) | `../archive/bookings_stg_code_review.md` |
|
||||
| `bookings_stg_design.md` | 5 | `docs/internal/bookings_tz.md` | `../reference/bookings_tz.md` |
|
||||
| `bookings_stg_design.md` | 130 | `docs/internal/bookings_tz.md` | `../reference/bookings_tz.md` |
|
||||
| `bookings_stg_design.md` | 131 | `docs/internal/pxf_bookings.md` | `../reference/pxf_bookings.md` |
|
||||
|
||||
| Файл (→ reference/) | Строка | Ссылка | Новый путь |
|
||||
|------|--------|--------|------|
|
||||
| `qa-plan.md` | 10 | `docs/internal/bookings_db_issues.md` | `bookings_db_issues.md` (тот же каталог) |
|
||||
|
||||
**B2. Внутрикаталожные, но с полным путём `docs/internal/...` (путь не сломается
|
||||
для ссылок с относительным target, но display-текст устареет):**
|
||||
|
||||
| Файл (→ design/) | Строки | Что обновить |
|
||||
|------|--------|------|
|
||||
| `PRD.md` | 209 | `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
|
||||
| `bookings_ods_design.md` | 48 | display-текст `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
|
||||
| `bookings_dds_design.md` | 8 | `docs/internal/bookings_ods_design.md` → `docs/design/bookings_ods_design.md` |
|
||||
| `bookings_dds_design.md` | 185 | display-текст `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
|
||||
| `bookings_dds_design.md` | 828-829 | `docs/internal/bookings_dds_design.md`, `docs/internal/db_schema.md` → `docs/design/...` |
|
||||
| `bookings_dds_design.md` | 945 | `docs/internal/db_schema.md` → `docs/design/db_schema.md` |
|
||||
| `bookings_dm_design.md` | 279 | `docs/internal/db_schema.md` → `docs/design/db_schema.md` |
|
||||
| `bookings_dm_design.md` | 283, 291 | `docs/internal/bookings_dm_design.md` → `docs/design/bookings_dm_design.md` |
|
||||
| `bookings_dm_design.md` | 395 | `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
|
||||
| `db_schema.md` | 26 | display-текст `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
|
||||
| `db_schema.md` | 421-423 | display-тексты `docs/internal/bookings_*_design.md` → `docs/design/...` |
|
||||
| `architecture_review.md` | 70 | `docs/internal/bookings_dm_design.md` → `docs/design/bookings_dm_design.md` |
|
||||
| `architecture_review.md` | 146 | `docs/internal/distribution_strategy.md` → **удалить путь** (файл не существует, оставить как текстовый backlog-пункт без ссылки) |
|
||||
|
||||
### Категория C. Ссылки на `educational-tasks.md`
|
||||
|
||||
| Файл | Действие |
|
||||
|------|----------|
|
||||
| `README.md` (корень) | Заменить ссылку на `educational-tasks.md` → `docs/assignment/` |
|
||||
| `docs/README.md` | Заменить ссылку на `educational-tasks.md` → `assignment/` |
|
||||
|
||||
### Категория D. Файлы в `docs/archive/` — НЕ ТРОГАЕМ
|
||||
|
||||
Архивные файлы замораживаются. Ссылки внутри них могут быть битыми — это ожидаемо.
|
||||
|
||||
### Комментарий в SQL
|
||||
|
||||
`sql/dm/sales_report_ddl.sql` строка 52: `naming_conventions.md` (без пути) —
|
||||
оставить как есть (комментарий, не ссылка; путь и так неточный).
|
||||
|
||||
---
|
||||
|
||||
## Фаза 4. Актуализация содержания
|
||||
|
||||
| Файл (новый путь) | Что сделать |
|
||||
|------|-------------|
|
||||
| `docs/design/architecture_review.md` | DM завершён (5/5 витрин), пометить выполненные P2; строка 146 — убрать путь к несуществующему `distribution_strategy.md`, оставить как текстовый backlog-пункт |
|
||||
| `docs/design/db_schema.md` | Добавить DM-слой, убрать выполненный TODO |
|
||||
| `TESTING.md` | Убрать артефакт «Docker-стенд не запускался» |
|
||||
| `docs/dag_execution_order.md` | Добавить все 4 DDL DAG-а |
|
||||
| `docs/reference/pxf_bookings.md` | Исправить нумерацию разделов (7→9→8→10 → последовательную) |
|
||||
|
||||
---
|
||||
|
||||
## Фаза 5. Обновление индексов и заглушка assignment
|
||||
|
||||
### `docs/README.md`
|
||||
|
||||
Переписать структуру: разделы по каталогам (`design/`, `reference/`, `plans/`,
|
||||
`archive/`, `assignment/`). Включить ранее пропущенные документы: `db_schema.md`,
|
||||
`dag_execution_order.md`, `e2e-etl-test-protocol.md`, `agent-dag-testing.md`.
|
||||
|
||||
### `README.md` (корень)
|
||||
|
||||
Заменить ссылку на `educational-tasks.md` → `docs/assignment/`, проверить остальные.
|
||||
|
||||
### `docs/assignment/README.md` (новый файл)
|
||||
|
||||
Временная заглушка:
|
||||
- Указание, что курсовые задания появятся в Этапе 3
|
||||
- Ссылка на эталонный срез (DAG-и STG→ODS→DDS→DM) для самостоятельного изучения
|
||||
- Ссылка на `docs/design/assignment_design.md` для менторов
|
||||
|
||||
---
|
||||
|
||||
## Фаза 6. Финальная проверка
|
||||
|
||||
Шаги выполняются строго по порядку:
|
||||
|
||||
1. [ ] `make test` — тесты проходят
|
||||
2. [ ] `make lint` — стиль кода
|
||||
3. [ ] Удалить пустой каталог `docs/internal/`
|
||||
4. [ ] Перенести план из `docs/plans/` в `docs/archive/docs_restructuring_plan.md`
|
||||
5. [ ] grep по `internal/` в живых .md файлах (`rg --glob '!docs/archive/*'`) — нет битых ссылок
|
||||
6. [ ] grep по `educational-tasks` в живых .md файлах (`rg --glob '!docs/archive/*'`) — нет битых ссылок
|
||||
|
||||
---
|
||||
|
||||
## Порядок выполнения
|
||||
|
||||
Фазы 1→2→3 делаются вместе (иначе ссылки будут битыми).
|
||||
Фаза 4 — независима, можно параллельно.
|
||||
Фаза 5 — после всех перемещений.
|
||||
Фаза 6 — в конце.
|
||||
|
||||
---
|
||||
|
||||
## Что это даёт следующим этапам
|
||||
|
||||
| Этап | Как помогает |
|
||||
|------|-------------|
|
||||
| **Этап 2** (подготовка main) | Чистая структура — понятно, что удалять, что оставлять |
|
||||
| **Этап 3** (ТЗ от аналитика) | Готовый каталог `docs/assignment/` с заглушкой, место для `analyst_spec.md` |
|
||||
| **Этап 4** (валидационный DAG) | `docs/reference/qa-plan.md` — рядом с другими справочными |
|
||||
| **Этап 5** (ветка solution) | Архив отделён — не попадёт в ветку solution |
|
||||
@@ -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` и мини‑README `docs/internal/bookings_stg_readme.md` (если интересно — `docs/internal/bookings_stg_design.md`).
|
||||
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,36 +49,36 @@
|
||||
- логическая дата запуска 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‑файлы:
|
||||
- `sql/src/bookings_generate_day_if_missing.sql`
|
||||
- `sql/stg/bookings_load.sql`
|
||||
- `sql/stg/bookings_dq.sql`
|
||||
3. Соотнесите шаги DAG с документом `docs/internal/bookings_stg_readme.md`:
|
||||
3. Соотнесите шаги DAG с документом `docs/bookings_to_gp_stage.md`:
|
||||
- генерация учебного дня в `bookings.bookings`;
|
||||
- загрузка инкремента в `stg.bookings`;
|
||||
- проверка количества строк между источником и STG.
|
||||
4. Обратите внимание, как в DAG используется логическая дата запуска:
|
||||
- `{{ ds_nodash }}` используется как `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 будут освоены.
|
||||
|
||||
Планируемые направления:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Унификация нейминга служебных полей в STG
|
||||
|
||||
## Контекст
|
||||
|
||||
В STG-слое используются legacy-имена служебных полей (`batch_id`, `load_dttm`, `src_created_at_ts`), а начиная с ODS — каноничные (`_load_id`, `_load_ts`, `event_ts`). Студент видит разные имена для одного понятия. Цель — привести STG к канону, убрав расхождение.
|
||||
|
||||
> **Breaking change (dev-only).** Это ломающее переименование колонок. Миграционный шаг (ALTER TABLE … RENAME COLUMN) не предусмотрен. DDL-файлы используют `CREATE TABLE IF NOT EXISTS`, поэтому сами по себе они не пересоздадут существующие таблицы с новыми именами колонок. План предполагает заранее пересозданную среду (например, `make down && make up`) или ручной `DROP TABLE` / `DROP SCHEMA` перед `make ddl-gp`. Обратная совместимость не обеспечивается.
|
||||
|
||||
## Маппинг
|
||||
|
||||
| Legacy (STG сейчас) | Канон (ODS/DDS/DM) |
|
||||
|---|---|
|
||||
| `batch_id` | `_load_id` |
|
||||
| `load_dttm` | `_load_ts` |
|
||||
| `src_created_at_ts` | `event_ts` |
|
||||
|
||||
### Оговорка про `event_ts` в snapshot-справочниках
|
||||
|
||||
В транзакционных STG-таблицах (bookings, tickets, flights, segments, boarding_passes) поле `src_created_at_ts` действительно хранит время события из источника — переименование в `event_ts` семантически точно.
|
||||
|
||||
В snapshot-справочниках (airports, airplanes, routes, seats) это поле заполняется `now()` при загрузке, т.е. по факту это ещё одно load-time, а не время события. Тем не менее мы сохраняем единое имя `event_ts` как **учебное упрощение** — ради консистентной структуры STG-таблиц. Это зафиксировано как осознанный trade-off: единообразие важнее семантической точности в справочниках. В `naming_conventions.md` нужно добавить соответствующую оговорку (раздел 4, «Time Rule»).
|
||||
|
||||
## Что НЕ переименовываем
|
||||
|
||||
- PL/pgSQL переменная `v_batch_id` — это локальная переменная, не колонка
|
||||
- Python-функция `_resolve_stg_batch_id`, переменная `stg_batch_id`, task_id `resolve_stg_batch_id` — это Python/Airflow-идентификаторы
|
||||
- XCom-ключи, ссылающиеся на task_id
|
||||
|
||||
## Порядок выполнения
|
||||
|
||||
### Шаг 1: STG DDL (9 файлов)
|
||||
|
||||
`sql/stg/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_ddl.sql`
|
||||
|
||||
В каждом: `batch_id` → `_load_id`, `load_dttm` → `_load_ts`, `src_created_at_ts` → `event_ts`.
|
||||
|
||||
### Шаг 2: STG Load (9 файлов)
|
||||
|
||||
`sql/stg/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_load.sql`
|
||||
|
||||
INSERT-списки, SELECT, WHERE, комментарии — те же 3 замены.
|
||||
|
||||
### Шаг 3: STG DQ (9 файлов)
|
||||
|
||||
`sql/stg/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_dq.sql`
|
||||
|
||||
WHERE-условия (`batch_id = v_batch_id` → `_load_id = v_batch_id`), RAISE-сообщения, комментарии.
|
||||
|
||||
### Шаг 4: ODS Load (9 файлов)
|
||||
|
||||
Два подтипа — обрабатывать по-разному.
|
||||
|
||||
#### 4a: Транзакционные таблицы (5 файлов)
|
||||
|
||||
`sql/ods/{bookings,tickets,flights,segments,boarding_passes}_load.sql`
|
||||
|
||||
SELECT из STG: `s.batch_id` → `s._load_id`, `s.load_dttm` → `s._load_ts`, `s.src_created_at_ts` → `s.event_ts`. Убрать лишние алиасы (`s.src_created_at_ts AS event_ts` → просто `s.event_ts`).
|
||||
|
||||
#### 4b: Snapshot-справочники (4 файла)
|
||||
|
||||
`sql/ods/{airports,airplanes,routes,seats}_load.sql`
|
||||
|
||||
Здесь `event_ts` отсутствует в целевой ODS-таблице — менять только ссылки на STG-колонки: `s.batch_id` → `s._load_id`, `s.load_dttm` → `s._load_ts`, `s.src_created_at_ts` → `s.event_ts` (только в ORDER BY / WHERE, где они читают из STG). ODS-колонка `_load_ts` по-прежнему заполняется через `now()`, это не меняется.
|
||||
|
||||
### Шаг 5: ODS DQ (9 файлов)
|
||||
|
||||
`sql/ods/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_dq.sql`
|
||||
|
||||
`WHERE batch_id =` → `WHERE _load_id =`, RAISE-сообщения.
|
||||
|
||||
### Шаг 6: DAG-файлы (2 файла)
|
||||
|
||||
- `airflow/dags/bookings_to_gp_stage.py` — комментарий про `batch_id`
|
||||
- `airflow/dags/bookings_to_gp_ods.py` — встроенный SQL-запрос резолвера: все `batch_id` как колонка → `_load_id`, `load_dttm` → `_load_ts`. Python-имена не трогаем.
|
||||
|
||||
### Шаг 7: Тесты (3 файла)
|
||||
|
||||
- `tests/test_ods_snapshot_integration.py` — inline DDL и INSERT в тестах
|
||||
- `tests/test_dags_smoke.py` — комментарии
|
||||
- `tests/test_ods_sql_contract.py` — docstring
|
||||
|
||||
### Шаг 8: Документация (~15 файлов)
|
||||
|
||||
- `docs/internal/naming_conventions.md` — убрать legacy-исключение (секция 5/STG), убрать переходный маппинг (секция 6), добавить оговорку про `event_ts` в snapshot-справочниках (секция 4)
|
||||
- `docs/internal/db_schema.md` — описания STG-полей
|
||||
- `docs/internal/bookings_stg_design.md` — дизайн STG
|
||||
- `docs/internal/bookings_ods_design.md` — маппинг STG→ODS, SQL-примеры
|
||||
- `docs/internal/qa-plan.md` — SQL-запросы проверок
|
||||
- `docs/internal/architecture_review.md` — архитектурные заметки
|
||||
- `docs/internal/bookings_stg_code_review.md` — код-ревью
|
||||
- `docs/bookings_to_gp_stage.md` — описание STG DAG, примеры полей
|
||||
- `docs/bookings_to_gp_ods.md` — описание ODS DAG
|
||||
- `docs/dag_execution_order.md` — порядок выполнения DAG
|
||||
- `educational-tasks.md` — учебные задания
|
||||
- `README.md` — SQL-примеры в README
|
||||
- `TESTING.md` — чек-лист тестирования
|
||||
|
||||
### Шаг 9: Верификация
|
||||
|
||||
```bash
|
||||
# 1. Проверить SQL и Python — не должно быть колонок batch_id
|
||||
# (допустимы только: v_batch_id, stg_batch_id, resolve_stg_batch_id)
|
||||
grep -rn 'batch_id' sql/stg/ sql/ods/ airflow/dags/ tests/ --include='*.sql' --include='*.py'
|
||||
|
||||
# 2. Ноль совпадений по старым именам в коде
|
||||
grep -rn 'load_dttm' sql/ airflow/dags/ tests/ --include='*.sql' --include='*.py'
|
||||
grep -rn 'src_created_at_ts' sql/ airflow/dags/ tests/ --include='*.sql' --include='*.py'
|
||||
|
||||
# 3. Проверить документацию — не должно быть старых имён как актуальных
|
||||
# (допустимы упоминания в историческом контексте)
|
||||
grep -rn 'batch_id\|load_dttm\|src_created_at_ts' docs/ educational-tasks.md README.md TESTING.md
|
||||
|
||||
# 4. Тесты и линтер
|
||||
make test
|
||||
make fmt && make lint
|
||||
```
|
||||
|
||||
## Итого: ~55 файлов, ~3 механические замены в каждом
|
||||
@@ -0,0 +1,88 @@
|
||||
# План: выравнивание качества документации ETL DAG'ов
|
||||
|
||||
## Контекст
|
||||
|
||||
Документ `bookings_to_gp_stage.md` — эталон: пошаговый разбор задач, ASCII-граф,
|
||||
ссылки на SQL-файлы, описание edge cases. Три остальных документа
|
||||
(`_ods`, `_dds`, `_dm`) значительно беднее. Цель — подтянуть их до того же уровня.
|
||||
|
||||
## Единый шаблон секций (целевая структура)
|
||||
|
||||
Каждый документ должен содержать:
|
||||
|
||||
1. **Заголовок + вводный абзац** (что за DAG, какой слой, зачем)
|
||||
2. **Что делает DAG** (буллеты, краткое описание)
|
||||
3. **Что должно быть готово** (prerequisites)
|
||||
4. **Как запустить** (UI + опциональные параметры)
|
||||
5. **Граф зависимостей** (ASCII-диаграмма, не буллет-лист)
|
||||
6. **Как это работает внутри (по шагам)** ← ГЛАВНОЕ ДОБАВЛЕНИЕ
|
||||
- Пронумерованные шаги: task_id → SQL-файл → что делает → паттерн (SCD1/SCD2/rebuild/HWM)
|
||||
- Учебные пояснения к нетривиальным паттернам
|
||||
7. **Как проверить результат** (SQL-запросы)
|
||||
8. **Типичные ошибки** (уже есть, оставляем)
|
||||
|
||||
## Что именно добавить/исправить в каждом документе
|
||||
|
||||
### A. `bookings_to_gp_ods.md`
|
||||
|
||||
**Текущее состояние:** 95 строк, нет пошагового разбора, нет ASCII-графа, нет SQL-путей.
|
||||
|
||||
| # | Что сделать | Детали |
|
||||
|---|-------------|--------|
|
||||
| A1 | ASCII-граф зависимостей | Заменить буллет-лист на диаграмму (как в stage). Показать параллельные ветки airports/airplanes, схождение на routes/seats, цепочку flights→segments→boarding_passes |
|
||||
| A2 | Секция "Как это работает внутри" | 10 шагов: resolve_stg_batch_id (Python, INTERSECT-логика), затем 9 пар load→dq с указанием SQL-файлов |
|
||||
| A3 | Пояснить паттерн SCD1 UPSERT | Кратко: TEMP TABLE → UPDATE (IS DISTINCT FROM) → INSERT. Одного абзаца достаточно, потом ссылка "паттерн одинаков для всех 9 таблиц" |
|
||||
| A4 | Описать разницу snapshot vs HWM | Snapshot-справочники фильтруются по `stg_batch_id`; транзакционные таблицы — по HWM (`_load_ts`). Объяснить почему (чтобы не терять инкременты при повторных запусках STG) |
|
||||
| A5 | Edge case: пустой батч | Для инкрементальных таблиц допустим; для snapshot — нет |
|
||||
|
||||
**Ожидаемый объём:** ~140–160 строк.
|
||||
|
||||
### B. `bookings_to_gp_dds.md`
|
||||
|
||||
**Текущее состояние:** 73 строки — самый бедный документ. Нет пошаговости, нет ASCII-графа, SCD2 не объяснён.
|
||||
|
||||
| # | Что сделать | Детали |
|
||||
|---|-------------|--------|
|
||||
| B1 | ASCII-граф зависимостей | calendar → параллельно 4 SCD1-измерения + dim_routes (после airports+airplanes) → fact (после всех dims) → summary |
|
||||
| B2 | Секция "Как это работает внутри" | 8 шагов: calendar (rebuild), 4×SCD1-измерения, dim_routes (SCD2), fact_flight_sales, summary |
|
||||
| B3 | Объяснить SCD2 для dim_routes | Учебный блок: hashdiff (MD5), закрытие старых версий, вставка новых, point-in-time valid_from. Это ключевой паттерн DDS — заслуживает 10–15 строк |
|
||||
| B4 | Объяснить Phase 3 (денормализация) | Обновление SCD1-атрибутов (города, модель) во ВСЕХ версиях dim_routes. Зачем: чтобы не хранить устаревшие названия городов |
|
||||
| B5 | Объяснить late-arriving dimensions | LEFT JOIN в fact_flight_sales: факт может прийти раньше справочника. 3–5 строк |
|
||||
| B6 | Объяснить point-in-time join | Как факт привязывается к правильной версии SCD2-маршрута по дате рейса |
|
||||
| B7 | Edge case: генерация SK | MAX() + ROW_NUMBER() безопасна только при concurrency=1 |
|
||||
|
||||
**Ожидаемый объём:** ~160–180 строк.
|
||||
|
||||
### C. `bookings_to_gp_dm.md`
|
||||
|
||||
**Текущее состояние:** 80 строк. ASCII-граф есть (хорошо!), описание стратегий загрузки есть (хорошо!), но нет пошагового разбора с SQL-файлами.
|
||||
|
||||
| # | Что сделать | Детали |
|
||||
|---|-------------|--------|
|
||||
| C1 | Секция "Как это работает внутри" | 5 шагов (по одному на витрину) + start_dm + finish_dm_summary. Для каждой витрины: task_id → SQL-файл → паттерн → зерно (grain) |
|
||||
| C2 | Расширить описание sales_report | HWM-паттерн: какие даты пересчитываются, NULLIF-guard для boarding_rate, автоматическая "догонка" при первичной загрузке |
|
||||
| C3 | Расширить описание route_performance | Почему Full Rebuild: AO Column (нет UPDATE/DELETE), таблица маленькая. 3-шаговый паттерн: TRUNCATE → агрегация по route_bk → JOIN с текущей версией SCD2 |
|
||||
| C4 | Добавить grain для каждой витрины | sales_report: (flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk); route_performance: route_bk; и т.д. |
|
||||
| C5 | Добавить SQL-пути к задачам | Сейчас нигде не указаны пути к SQL-файлам |
|
||||
|
||||
**Ожидаемый объём:** ~140–160 строк.
|
||||
|
||||
### D. Мелкие правки в `bookings_to_gp_stage.md` (опционально)
|
||||
|
||||
| # | Что сделать | Детали |
|
||||
|---|-------------|--------|
|
||||
| D1 | Убедиться в консистентности шаблона | Если в ходе работы над ODS/DDS/DM выработается чуть лучшая структура — привести stage к тому же формату (только структурные правки, контент не менять) |
|
||||
|
||||
## Порядок работы
|
||||
|
||||
1. **ODS** (средняя сложность, знакомый паттерн SCD1)
|
||||
2. **DDS** (наибольшая учебная ценность — SCD2, late-arriving dims)
|
||||
3. **DM** (наименьший объём правок — ASCII-граф уже есть)
|
||||
4. **Stage** — только если нужна косметика для консистентности
|
||||
|
||||
## Принципы
|
||||
|
||||
- Не раздувать: целевой объём каждого документа — 140–180 строк (stage = 157).
|
||||
- Учебная ценность > полнота: объяснять «почему», а не перечислять все колонки.
|
||||
- SQL-пути обязательны — это главный навигационный инструмент для студента.
|
||||
- Один паттерн объясняем один раз подробно, дальше ссылаемся: "паттерн аналогичен X".
|
||||
@@ -0,0 +1,503 @@
|
||||
# Plan: Перенос STG в эталон, ODS airplanes+seats — студенту
|
||||
|
||||
> Версия: 4 (после третьего ревью ChatGPT 5.4)
|
||||
> Дата: 2026-03-11
|
||||
|
||||
> **Статус выполнения (2026-03-12):**
|
||||
> - Секция «Сейчас (на chore/bookings-etl)» — ✅ выполнена (п.1, п.6, п.7, п.9)
|
||||
> - Секция «Этап 3 (валидационный DAG)» — ✅ выполнена (см. `docs/archive/2026-03-12_validation-dag.md`)
|
||||
> - Секция «Этап 4 (подготовка main + solution)» — ⬜ не выполнена.
|
||||
> Детальные спецификации кода (п.2–5) и документации (п.8, п.10–11) остаются
|
||||
> актуальным справочником. Порядок выполнения и стратегия веток —
|
||||
> в новом плане `docs/plans/2026-03-12_main-solution-split.md`.
|
||||
|
||||
## Контекст и мотивация
|
||||
|
||||
### Обнаруженная проблема
|
||||
|
||||
При подготовке к Этапу 4 (раскладка по веткам main / solution) обнаружен **конфликт
|
||||
между текущим дизайном БД** (`docs/design/db_schema.md`, `docs/design/bookings_dds_design.md`)
|
||||
**и планом курсового задания** (`docs/design/assignment_design.md`).
|
||||
|
||||
Суть конфликта: эталонный пайплайн (который должен работать «из коробки» на main-ветке
|
||||
для студента) **неработоспособен**, если студент ещё не реализовал ни одного задания.
|
||||
Три DDS-измерения (`dim_routes`, `dim_passengers`, `dim_airplanes`) отнесены к заданию
|
||||
студента, но эталонная фактовая таблица `fact_flight_sales` зависит от них через
|
||||
цепочку LEFT JOIN. Без этих измерений факт загружается с массовыми NULL в ключевых FK,
|
||||
а DQ-проверки блокируют pipeline. Эталонные витрины (`sales_report`, `airport_traffic`)
|
||||
становятся бесполезными.
|
||||
|
||||
Проблема усугубляется архитектурой batch resolver ODS DAG и тестовыми контрактами,
|
||||
которые требуют данных во **всех** snapshot-таблицах STG.
|
||||
|
||||
Ниже — детальный разбор и выбранное решение.
|
||||
|
||||
### Фактовая таблица: зависимость от студенческих измерений
|
||||
|
||||
`fact_flight_sales` зависит от **всех 7 измерений** DDS,
|
||||
включая 3 студенческих: `dim_routes`, `dim_passengers`, `dim_airplanes`.
|
||||
|
||||
Критическая цепочка: аэропорты в факте разрешаются **через `dim_routes`**:
|
||||
|
||||
```
|
||||
flt.route_no → dim_routes.route_bk → rte.departure_airport → dim_airports.airport_bk → airport_sk
|
||||
```
|
||||
|
||||
Если `dim_routes` пуст (студент ещё не реализовал), то **NULL** получают не только
|
||||
`route_sk`, но и оба `airport_sk` и `airplane_sk`. Эталонная витрина `sales_report`
|
||||
становится бесполезной.
|
||||
|
||||
Причина: `ods.flights` содержит только `route_no`, но не `departure_airport` /
|
||||
`arrival_airport` / `aircraft_code`. Эти поля доступны только через таблицу `routes`.
|
||||
|
||||
### Почему нельзя просто использовать DDL-заглушки
|
||||
|
||||
Если `dim_routes` — пустая заглушка, все LEFT JOIN через неё возвращают NULL.
|
||||
Факт загружается, но:
|
||||
|
||||
- `departure_airport_sk = NULL` → `sales_report` не может группировать по аэропортам
|
||||
- `arrival_airport_sk = NULL` → `airport_traffic` не может считать трафик
|
||||
- UPDATE факта **не перезаписывает SK** (by design, строка 4-5 load-скрипта) →
|
||||
NULL-и остаются навсегда до TRUNCATE + перезагрузки
|
||||
|
||||
### Дополнительные архитектурные ограничения (из ревью)
|
||||
|
||||
1. **Fact DQ блокирует загрузку** (`fact_flight_sales_dq.sql`):
|
||||
- Строка 71: `passenger_sk IS NULL` → **hard fail** (100% NULL → exception)
|
||||
- Строка 98: `route_sk / airplane_sk IS NULL` → при >1% строк → **exception**
|
||||
- На main с пустыми студенческими dims — 100% NULL → pipeline падает
|
||||
|
||||
2. **Batch resolver ODS DAG** (`bookings_to_gp_ods.py:59-77`):
|
||||
- `INTERSECT` по `stg.airports`, `stg.airplanes`, `stg.routes`, `stg.seats`
|
||||
- Если **любая** STG-таблица пуста — общего batch нет → весь ODS pipeline падает
|
||||
- Контракт зафиксирован тестом `test_ods_sql_contract.py:30`
|
||||
|
||||
### Выбранное решение
|
||||
|
||||
**Весь STG-слой — эталонный** (все 9 таблиц загружаются reference-кодом).
|
||||
Это гарантирует:
|
||||
- Batch resolver всегда находит согласованный batch (все 4 snapshot-таблицы заполнены)
|
||||
- DAG-зависимости и smoke-тесты STG не требуют изменений
|
||||
- Никакого расхождения batch-контракта между main и solution
|
||||
|
||||
**ODS: `airplanes` + `seats` остаются заданием студента** (TRUNCATE+INSERT практика).
|
||||
|
||||
**DDS: `dim_airplanes`, `dim_passengers`, `dim_routes`** — задание студента (DDL-заглушки).
|
||||
|
||||
**Fact lookup**: аэропорты через `ods.routes` (эталон); `airplane_sk` остаётся
|
||||
point-in-time через `dim_routes` (NULL на main — допустимо).
|
||||
|
||||
**Fact DQ**: на main ослабить проверки студенческих SK.
|
||||
|
||||
### Что теряет студент (и почему это приемлемо)
|
||||
|
||||
| Потеря | Компенсация |
|
||||
|--------|-------------|
|
||||
| STG-практика (PXF external tables) | Изучает эталонный STG-код; отдельный PXF-практикум в бэклоге (`assignment_design.md`, раздел 6) |
|
||||
| `ods.routes` (TRUNCATE+INSERT) | Остаются `ods.airplanes` + `ods.seats` — тот же паттерн |
|
||||
|
||||
Ключевые элементы задания **сохранены**:
|
||||
- ODS: 2 таблицы (airplanes, seats) — практика TRUNCATE+INSERT
|
||||
- DDS: `dim_airplanes` (SCD1), `dim_passengers` (SCD1), **`dim_routes` (SCD2)** — ключевой вызов
|
||||
- DM: 4 витрины разной сложности (от простой к сложной)
|
||||
|
||||
### Пересчёт факта после реализации студентом
|
||||
|
||||
После реализации всех измерений студенту нужно:
|
||||
1. Запустить загрузку DDS-измерений (dim_airplanes, dim_passengers, dim_routes)
|
||||
2. `TRUNCATE dds.fact_flight_sales;`
|
||||
3. Перезапустить загрузку факта → теперь все SK заполнены
|
||||
4. Перезапустить DM-витрины
|
||||
|
||||
Это стандартная практика при late-arriving dimensions и хорошая обучающая точка.
|
||||
|
||||
### airport_traffic — студенческое задание
|
||||
|
||||
`airport_traffic` архитектурно не зависит от студенческих dims (использует только
|
||||
`calendar_sk`, `departure_airport_sk`, `arrival_airport_sk`). Но это **не значит**,
|
||||
что витрина готова: SQL витрины пишет студент. Данные в факте есть (эталон),
|
||||
студент пишет агрегирующий SQL. Статус: **задание студента** (как и остальные 3 DM).
|
||||
|
||||
---
|
||||
|
||||
## Матрица зависимостей DM-витрин
|
||||
|
||||
| DM витрина | calendar | airports | tariffs | routes | passengers | airplanes | Работает без студ. dims? |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `sales_report` (эталон) | + | + | + | — | — | — | **ДА** |
|
||||
| `airport_traffic` (студент) | + | + | — | — | — | — | Данные есть, SQL пишет студент |
|
||||
| `route_performance` (студент) | + | — | — | **+** | — | — | Нет |
|
||||
| `monthly_overview` (студент) | + | — | — | **+** | **+** | **+** | Нет |
|
||||
| `passenger_loyalty` (студент) | + | — | + | **+** | **+** | — | Нет |
|
||||
|
||||
---
|
||||
|
||||
## Изменения (код)
|
||||
|
||||
### 1. `sql/dds/fact_flight_sales_load.sql` — lookup аэропортов через ods.routes
|
||||
|
||||
**Ветка:** chore/bookings-etl (сейчас) + main (Этап 4)
|
||||
|
||||
**Текущее состояние** (строки 51-62):
|
||||
```sql
|
||||
LEFT JOIN dds.dim_routes AS rte
|
||||
ON rte.route_bk = flt.route_no
|
||||
AND flt.scheduled_departure::DATE >= rte.valid_from
|
||||
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
|
||||
...
|
||||
LEFT JOIN dds.dim_airports AS dep
|
||||
ON dep.airport_bk = rte.departure_airport -- через dim_routes!
|
||||
LEFT JOIN dds.dim_airports AS arr
|
||||
ON arr.airport_bk = rte.arrival_airport -- через dim_routes!
|
||||
LEFT JOIN dds.dim_airplanes AS ap
|
||||
ON ap.airplane_bk = rte.airplane_code -- через dim_routes!
|
||||
```
|
||||
|
||||
**После изменения:**
|
||||
```sql
|
||||
-- Учебный комментарий: Airport lookup через ods.routes (эталонный справочник),
|
||||
-- а не через dds.dim_routes (студенческое задание SCD2).
|
||||
-- Это архитектурное решение: эталонный пайплайн работает независимо от студенческого кода.
|
||||
-- ROW_NUMBER по validity DESC: выбираем актуальную версию расписания маршрута.
|
||||
-- Аэропорты вылета/прилёта одинаковы во всех версиях одного route_no.
|
||||
LEFT JOIN (
|
||||
SELECT route_no, departure_airport, arrival_airport
|
||||
FROM (
|
||||
SELECT route_no, departure_airport, arrival_airport,
|
||||
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
|
||||
FROM ods.routes
|
||||
) ranked
|
||||
WHERE rn = 1
|
||||
) AS ods_rte ON ods_rte.route_no = flt.route_no
|
||||
LEFT JOIN dds.dim_routes AS rte
|
||||
ON rte.route_bk = flt.route_no
|
||||
AND flt.scheduled_departure::DATE >= rte.valid_from
|
||||
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
|
||||
...
|
||||
LEFT JOIN dds.dim_airports AS dep
|
||||
ON dep.airport_bk = ods_rte.departure_airport -- через ods.routes (эталон)
|
||||
LEFT JOIN dds.dim_airports AS arr
|
||||
ON arr.airport_bk = ods_rte.arrival_airport -- через ods.routes (эталон)
|
||||
LEFT JOIN dds.dim_airplanes AS ap
|
||||
ON ap.airplane_bk = rte.airplane_code -- через dim_routes (point-in-time, как прежде)
|
||||
```
|
||||
|
||||
**Почему аэропорты через ods.routes, а airplane через dim_routes:**
|
||||
|
||||
- **Аэропорты**: `departure_airport` и `arrival_airport` одинаковы во всех версиях
|
||||
одного `route_no` (маршрут SVO→LED всегда SVO→LED). Безопасно брать из ODS.
|
||||
Это даёт эталонным витринам (`sales_report`, `airport_traffic`) корректные `airport_sk`
|
||||
даже без студенческого `dim_routes`.
|
||||
|
||||
- **Самолёт**: `airplane_code` теоретически может отличаться между версиями маршрута.
|
||||
Текущий контракт DDS (`bookings_dds_design.md:499`) фиксирует, что `airplane_sk`
|
||||
приходит из **той же point-in-time версии** маршрута, что и `route_sk`.
|
||||
Менять эту семантику — изменение модели данных, а не стабилизация.
|
||||
На main `airplane_sk` будет NULL (dim_routes — заглушка). Это допустимо:
|
||||
ни `sales_report`, ни `airport_traffic` не используют `airplane_sk`.
|
||||
|
||||
### 2. `sql/dds/fact_flight_sales_dq.sql` — ослабить проверки студенческих SK (только main)
|
||||
|
||||
**Ветка:** только main (Этап 4). На solution-ветке полные проверки сохраняются.
|
||||
|
||||
Изменения:
|
||||
|
||||
- **Строки 65-75** (`passenger_sk IS NULL`): заменить `RAISE EXCEPTION` на `RAISE NOTICE`.
|
||||
На main `dim_passengers` — заглушка, 100% строк будут с `passenger_sk IS NULL`.
|
||||
Любой порог (даже >1%) даст exception. **Только логирование, без блокировки.**
|
||||
|
||||
- **Строки 89-108** (route-related FK): разделить на два блока:
|
||||
|
||||
**Блок A — эталонные FK (проверка с порогом 1%, как calendar_sk):**
|
||||
```sql
|
||||
-- departure_airport_sk и arrival_airport_sk заполняются через ods.routes (эталон).
|
||||
-- NULL здесь — аномалия данных (пропущен маршрут в ODS), а не отсутствие студенческого кода.
|
||||
-- Порог 1% — защита от единичных аномалий источника (аналогично calendar_sk).
|
||||
SELECT COUNT(*) INTO v_null_airport
|
||||
FROM dds.fact_flight_sales
|
||||
WHERE departure_airport_sk IS NULL OR arrival_airport_sk IS NULL;
|
||||
|
||||
IF v_null_airport > 0 THEN
|
||||
IF v_null_airport * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN
|
||||
RAISE EXCEPTION 'DQ FAILED: NULL airport_sk: % (>1%%)', v_null_airport;
|
||||
ELSE
|
||||
RAISE NOTICE 'DQ WARNING: NULL airport_sk: % (<=1%%, допустимо)', v_null_airport;
|
||||
END IF;
|
||||
END IF;
|
||||
```
|
||||
|
||||
**Блок B — студенческие FK (только логирование, RAISE NOTICE):**
|
||||
```sql
|
||||
-- route_sk и airplane_sk будут NULL, пока студент не реализует dim_routes.
|
||||
-- Не блокируем pipeline.
|
||||
SELECT COUNT(*) INTO v_null_student
|
||||
FROM dds.fact_flight_sales
|
||||
WHERE route_sk IS NULL OR airplane_sk IS NULL;
|
||||
|
||||
IF v_null_student > 0 THEN
|
||||
RAISE NOTICE 'DQ INFO: студенческие SK (route/airplane) NULL: %. '
|
||||
'После реализации dim_routes: TRUNCATE fact → перезагрузка.',
|
||||
v_null_student;
|
||||
END IF;
|
||||
```
|
||||
|
||||
Итоговое правило на main:
|
||||
- `departure_airport_sk`, `arrival_airport_sk` — **проверка с порогом 1%** (эталон, через ods.routes; >1% → EXCEPTION, <=1% → NOTICE)
|
||||
- `passenger_sk`, `route_sk`, `airplane_sk` — **только RAISE NOTICE** (студенческие заглушки, 100% NULL допустимо)
|
||||
|
||||
```sql
|
||||
-- Учебный комментарий: route_sk, airplane_sk и passenger_sk будут NULL,
|
||||
-- пока вы не реализуете соответствующие DDS-измерения.
|
||||
-- После реализации: TRUNCATE dds.fact_flight_sales → перезагрузка → все SK заполнены.
|
||||
-- Полную версию DQ (с блокировкой) см. в ветке solution.
|
||||
```
|
||||
|
||||
### 3. ODS Routes DQ: убрать RI-проверку airplane_code (только main)
|
||||
|
||||
**Ветка:** только main (Этап 4). На solution-ветке полные проверки сохраняются.
|
||||
|
||||
> **Почему только ODS, не STG?** Весь STG — эталон, `stg.airplanes` всегда заполнен,
|
||||
> `stg/routes_dq.sql` RI-проверка к `stg.airplanes` проходит штатно. Менять STG не нужно.
|
||||
> На уровне ODS `ods.airplanes` — студенческая заглушка (пустая), поэтому
|
||||
> `ods/routes_dq.sql` RI-проверка к `ods.airplanes` упадёт.
|
||||
|
||||
**Файл:**
|
||||
- `sql/ods/routes_dq.sql` — удалить блок строк 143-156 (RI к `ods.airplanes`)
|
||||
|
||||
Добавить комментарий:
|
||||
```sql
|
||||
-- Учебный комментарий: проверка RI airplane_code → ods.airplanes не выполняется,
|
||||
-- т.к. таблица ods.airplanes реализуется студентом. После реализации — раскомментируйте
|
||||
-- (см. ветку solution для полной версии).
|
||||
```
|
||||
|
||||
### 4. ODS DAG-зависимость: убрать airplanes → routes (только main)
|
||||
|
||||
**Ветка:** только main (Этап 4)
|
||||
|
||||
> **Почему только ODS, не STG?** STG DAG не меняется: `stg.airplanes` — эталон,
|
||||
> барьер `check_airplanes_dq >> load_routes_to_stg` работает штатно.
|
||||
> На уровне ODS `dq_ods_airplanes` — заглушка (`SELECT 1;`), формально проходит,
|
||||
> но зависимость вводит в заблуждение. Убираем для ясности.
|
||||
|
||||
`airflow/dags/bookings_to_gp_ods.py` (строка 266):
|
||||
```python
|
||||
# Было:
|
||||
[dq_ods_airports, dq_ods_airplanes] >> load_ods_routes
|
||||
# Стало:
|
||||
dq_ods_airports >> load_ods_routes
|
||||
```
|
||||
|
||||
### 5. Заглушки для студенческих файлов (только main)
|
||||
|
||||
**Ветка:** только main (Этап 4)
|
||||
|
||||
#### STG-слой: весь код остаётся (эталон), заглушки не нужны
|
||||
|
||||
Все 9 STG-таблиц загружаются эталонным кодом. Batch resolver работает без изменений.
|
||||
|
||||
#### ODS-слой: заглушки для airplanes + seats
|
||||
|
||||
| Файл | main (студент) | solution |
|
||||
|------|----------------|----------|
|
||||
| `sql/ods/airplanes_ddl.sql` | Полный DDL (таблица нужна) | Тот же |
|
||||
| `sql/ods/airplanes_load.sql` | Заглушка: `SELECT 1; -- TODO` | Полная реализация |
|
||||
| `sql/ods/airplanes_dq.sql` | Заглушка: `SELECT 1;` | Полная реализация |
|
||||
| `sql/ods/seats_ddl.sql` | Полный DDL | Тот же |
|
||||
| `sql/ods/seats_load.sql` | Заглушка | Полная реализация |
|
||||
| `sql/ods/seats_dq.sql` | Заглушка | Полная реализация |
|
||||
|
||||
#### DDS-слой: заглушки для 3 измерений
|
||||
|
||||
| Файл | main (студент) | solution |
|
||||
|------|----------------|----------|
|
||||
| `sql/dds/dim_routes_ddl.sql` | Полный DDL (нужен для LEFT JOIN) | Тот же |
|
||||
| `sql/dds/dim_routes_load.sql` | Заглушка: `SELECT 1; -- TODO: реализуйте SCD2` | Полная реализация |
|
||||
| `sql/dds/dim_routes_dq.sql` | Заглушка: `SELECT 1;` | Полная реализация |
|
||||
| `sql/dds/dim_passengers_ddl.sql` | Полный DDL | Тот же |
|
||||
| `sql/dds/dim_passengers_load.sql` | Заглушка | Полная реализация |
|
||||
| `sql/dds/dim_passengers_dq.sql` | Заглушка | Полная реализация |
|
||||
| `sql/dds/dim_airplanes_ddl.sql` | Полный DDL | Тот же |
|
||||
| `sql/dds/dim_airplanes_load.sql` | Заглушка | Полная реализация |
|
||||
| `sql/dds/dim_airplanes_dq.sql` | Заглушка | Полная реализация |
|
||||
|
||||
#### DM-слой: заглушки для 4 витрин
|
||||
|
||||
Аналогично: DDL остаётся, load/dq заменяются заглушками.
|
||||
Файлы: `airport_traffic`, `route_performance`, `monthly_overview`, `passenger_loyalty`.
|
||||
|
||||
---
|
||||
|
||||
## Изменения (документация)
|
||||
|
||||
### 6. `docs/assignment/analyst_spec.md`
|
||||
|
||||
- **Удалить** разделы 1.1-1.3 (все STG-задания) — весь STG теперь эталон
|
||||
- **Удалить** разделы 2.3 (`ods.routes`) — routes теперь эталон
|
||||
- **Обновить** рекомендуемый порядок: начинается с ODS (airplanes, seats)
|
||||
- **Добавить** мотивационную секцию «Почему STG уже реализован»:
|
||||
- Эталонный пайплайн требует согласованного batch по всем snapshot-таблицам
|
||||
- Без данных в STG невозможна загрузка ODS и далее по цепочке
|
||||
- Студент изучает эталонный STG-код как образец
|
||||
- **Добавить** секцию «Пересчёт факта после реализации измерений»:
|
||||
- Инструкция: TRUNCATE fact + re-run после реализации всех DDS-измерений
|
||||
- Педагогическая ценность: late-arriving dimensions, dependency management
|
||||
|
||||
### 7. `docs/design/assignment_design.md`
|
||||
|
||||
- **Обновить** таблицу «Эталонные таблицы»: добавить весь STG + ods.routes
|
||||
- **Обновить** таблицу «Задание студенту»: убрать STG целиком, ODS оставить airplanes+seats
|
||||
- **Обновить** рекомендуемый порядок: начинается с ODS
|
||||
- **Обновить** структуру валидационного DAG (раздел 4): убрать `validate_stg` секцию
|
||||
|
||||
### 8. `docs/design/bookings_dds_design.md`
|
||||
|
||||
- **Строки 463, 468** (SQL-скелет факта): обновить JOIN-блок —
|
||||
`dep.airport_bk` и `arr.airport_bk` теперь через `ods_rte`, а не через `rte`;
|
||||
`ap.airplane_bk` остаётся через `rte` (point-in-time)
|
||||
- **Строка 499-501** (текстовое пояснение): обновить —
|
||||
`departure_airport_sk` и `arrival_airport_sk` берутся из `ods.routes` (эталон);
|
||||
`airplane_sk` остаётся point-in-time через `dim_routes`
|
||||
- **Строка 558** (passenger_sk = 0): убрать утверждение о нулевой толерантности —
|
||||
на main `passenger_sk` будет 100% NULL (dim_passengers — заглушка)
|
||||
- **Строка 559** (DQ текстовое описание): обновить — на main student SK
|
||||
(`passenger_sk`, `route_sk`, `airplane_sk`) не блокируют pipeline;
|
||||
`airport_sk` проверяются с порогом 1%
|
||||
- **Строка 741** (DQ SQL-пример): обновить встроенный SQL — разделить route-related
|
||||
блок на эталонный (airport_sk, порог 1%) и студенческий (route_sk, airplane_sk, NOTICE)
|
||||
|
||||
### 9. `docs/design/db_schema.md`
|
||||
|
||||
- Пометить весь STG и `ods.routes` как эталонные (не студенческие)
|
||||
- Строка 3: «Все слои реализованы» — уточнить, что на main ODS airplanes/seats,
|
||||
DDS student dims и DM student vitrines — заглушки
|
||||
- Строка 253: DQ-контракт «SQL-скрипты с RAISE EXCEPTION» — добавить оговорку,
|
||||
что на main студенческие DQ-скрипты являются заглушками
|
||||
|
||||
### 10. Тесты
|
||||
|
||||
- `tests/test_dags_smoke.py` строки 242-243:
|
||||
На main убрать assert барьера `dq_ods_airplanes → dq_ods_routes` (ODS routes
|
||||
не зависит от ODS airplanes на main). STG-барьеры (строки 123-124) **не трогаем**:
|
||||
STG целиком эталонный, барьер `check_airplanes_dq → check_routes_dq` работает.
|
||||
- `tests/test_ods_sql_contract.py` строка 6:
|
||||
На main изменить `SNAPSHOT_ENTITIES` — исключить студенческие ODS:
|
||||
```python
|
||||
# Было:
|
||||
SNAPSHOT_ENTITIES = ("airports", "airplanes", "routes", "seats")
|
||||
# Стало (main):
|
||||
SNAPSHOT_ENTITIES = ("airports", "routes")
|
||||
```
|
||||
Тесты `test_snapshot_load_scripts_use_truncate` (строка 13) и
|
||||
`test_snapshot_dq_checks_extra_keys` (строка 22) проверяют паттерны TRUNCATE+INSERT
|
||||
и `v_extra_keys_count` в SQL-файлах. Заглушки `SELECT 1;` не содержат этих паттернов →
|
||||
тесты упадут для airplanes/seats. Исключение из `SNAPSHOT_ENTITIES` решает проблему.
|
||||
Batch resolver тест (строка 30) проверяет DAG-код (не SQL-файлы) — без изменений.
|
||||
|
||||
### 11. Прочие документы
|
||||
|
||||
- `docs/design/PRD.md` — обновить чеклист (раздел 10)
|
||||
- `docs/design/bookings_ods_design.md` — комплексное обновление:
|
||||
- Строка 29, 515, 534: пометить `airplanes` и `seats` как студенческие
|
||||
(на main — заглушки; STG-уровень — эталон, ODS load/dq — студент)
|
||||
- Строки 521, 523: скорректировать общий DQ-контракт — на main `airplanes_dq.sql`
|
||||
и `seats_dq.sql` являются заглушками, а не полноценными RAISE EXCEPTION скриптами
|
||||
- Строка 620: обновить DAG-граф — routes больше не зависит от airplanes на ODS
|
||||
- Строка 634: убрать зависимость routes от airplanes в описании FK
|
||||
- `docs/bookings_to_gp_ods.md` — обновить описание
|
||||
- `docs/bookings_to_gp_dds.md`:
|
||||
- Строки 111-113: убрать гарантию отсутствия NULL SK в штатном режиме —
|
||||
на main student SK (`passenger_sk`, `route_sk`, `airplane_sk`) будут NULL
|
||||
- Строка 116: обновить DQ-семантику — student SK не блокируют (NOTICE);
|
||||
airport_sk — порог 1%
|
||||
- Строка 126: обновить описание lookup (airports через ods.routes)
|
||||
- `docs/reference/qa-plan.md`:
|
||||
- Строка 80: «ODS: все 9 таблиц не пустые» — на main `ods.airplanes` и `ods.seats`
|
||||
будут пустыми by design (студенческие заглушки). Уточнить формулировку.
|
||||
- `docs/e2e-etl-test-protocol.md` — обновить контекст
|
||||
|
||||
---
|
||||
|
||||
## Порядок выполнения
|
||||
|
||||
### Сейчас (на chore/bookings-etl)
|
||||
|
||||
1. **Код**: изменить `fact_flight_sales_load.sql` (lookup через ods.routes) — п.1
|
||||
2. **Документация**: обновить `analyst_spec.md` — п.6
|
||||
3. **Документация**: обновить `assignment_design.md` — п.7
|
||||
4. **Документация**: обновить `db_schema.md` — п.9
|
||||
5. **Тестирование**: `make test` + ручная проверка SQL
|
||||
6. **Коммит**
|
||||
|
||||
### Этап 3 (валидационный DAG)
|
||||
|
||||
- Проектировать с учётом DDL-заглушек
|
||||
- `validate_stg` секцию **не делать** (STG — эталон)
|
||||
- `check_dim_routes_scd2` — активный тест (мутация ODS → проверка → откат)
|
||||
|
||||
### Этап 4 (подготовка main + solution)
|
||||
|
||||
- Fact DQ: ослабить проверки student SK (main only) — п.2
|
||||
- Routes DQ: убрать airplane RI (main only) — п.3
|
||||
- DAG-зависимости: убрать airplanes → routes (main only) — п.4
|
||||
- Заглушки: ODS (airplanes, seats) + DDS (3 dims) + DM (4 vitrines) — п.5
|
||||
- Тесты: обновить smoke-тесты (main only) — п.10
|
||||
- Документация: bookings_dds_design, PRD, прочее — п.8, п.11
|
||||
|
||||
---
|
||||
|
||||
## Верификация
|
||||
|
||||
### После п.1 (fact_flight_sales fix)
|
||||
|
||||
```bash
|
||||
make test # smoke-тесты DAG
|
||||
```
|
||||
|
||||
Ручная проверка (при поднятом стенде):
|
||||
|
||||
> **Важно:** UPDATE факта не перезаписывает dimension SK (by design, строка 4-5).
|
||||
> Уже загруженные строки сохранят старые значения `airport_sk` (через dim_routes).
|
||||
> Для полной проверки новой логики нужен **TRUNCATE + перезагрузка**:
|
||||
|
||||
```sql
|
||||
TRUNCATE dds.fact_flight_sales;
|
||||
-- Перезапустить DDS DAG (или только task load_dds_fact_flight_sales)
|
||||
|
||||
-- Проверка: airport_sk заполнены (через ods.routes), route_sk тоже (dim_routes заполнен на этой ветке)
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(departure_airport_sk) AS has_dep_sk,
|
||||
COUNT(arrival_airport_sk) AS has_arr_sk,
|
||||
COUNT(route_sk) AS has_route_sk
|
||||
FROM dds.fact_flight_sales;
|
||||
```
|
||||
|
||||
### После Этапа 4 (main branch)
|
||||
|
||||
1. `make up` → `make ddl-gp` → запустить STG+ODS+DDS DAG-и
|
||||
2. Проверить: `sales_report` содержит данные с корректными аэропортами
|
||||
3. Проверить: студенческие ODS/DDS-таблицы пусты (заглушки)
|
||||
4. Fact DQ проходит (student SK = NULL, но DQ не блокирует)
|
||||
5. Реализовать один студенческий dim → TRUNCATE fact → re-run → проверить SK
|
||||
|
||||
---
|
||||
|
||||
## Ключевые файлы
|
||||
|
||||
| Файл | Роль | Ветка |
|
||||
|------|------|-------|
|
||||
| `sql/dds/fact_flight_sales_load.sql` | Главное изменение: airport lookup через ods.routes | обе |
|
||||
| `sql/dds/fact_flight_sales_dq.sql` | Student SK: EXCEPTION → NOTICE | main only |
|
||||
| `sql/ods/routes_dq.sql` | Убрать RI airplane_code → ods.airplanes | main only |
|
||||
| `airflow/dags/bookings_to_gp_ods.py` | DAG: убрать airplanes → routes | main only |
|
||||
| `tests/test_ods_sql_contract.py` | SNAPSHOT_ENTITIES: убрать airplanes, seats | main only |
|
||||
| `tests/test_dags_smoke.py` | ODS-барьер airplanes → routes | main only |
|
||||
| `docs/assignment/analyst_spec.md` | Убрать STG-задания, убрать ods.routes | обе |
|
||||
| `docs/design/assignment_design.md` | Обновить таблицы эталон/задание | обе |
|
||||
| `docs/design/bookings_dds_design.md` | Обновить описание fact lookup | обе |
|
||||
| `docs/design/bookings_ods_design.md` | Убрать зависимость routes от airplanes | main only |
|
||||
| `docs/design/db_schema.md` | Пометить STG + ods.routes как эталон | обе |
|
||||
|
||||
> **STG не трогаем**: `sql/stg/routes_dq.sql`, `bookings_to_gp_stage.py`,
|
||||
> STG smoke-тесты — без изменений. Весь STG эталонный, зависимости работают штатно.
|
||||
@@ -0,0 +1,999 @@
|
||||
# План: Валидационный DAG `bookings_validate`
|
||||
|
||||
## Контекст и мотивация
|
||||
|
||||
### Проблема
|
||||
|
||||
Студент реализует 9 объектов (2 ODS, 3 DDS-измерения, 4 DM-витрины) и **не имеет
|
||||
автоматической обратной связи** — правильно ли работает его код. Сейчас единственный
|
||||
способ проверки — ручные SQL-запросы и визуальный контроль данных в таблицах.
|
||||
|
||||
Это приводит к типичным ошибкам, которые студент не замечает:
|
||||
|
||||
| Ошибка | Где проявляется | Когда обнаруживается |
|
||||
|--------|-----------------|----------------------|
|
||||
| NULL в PK | ODS/DDS | Только при построении витрины (неожиданные NULL в агрегатах) |
|
||||
| Дубли по BK | DDS dim_passengers | Факт раздувается, витрины считают неверно |
|
||||
| SCD2 не закрывает версии | DDS dim_routes | `valid_to` всегда NULL → point-in-time JOIN перестаёт работать |
|
||||
| «Дыры» в SCD2-интервалах | DDS dim_routes | `sales_report` теряет строки за «пустые» даты |
|
||||
| TRUNCATE+INSERT не вытащил все строки | ODS airplanes/seats | DDS-измерение неполное → NULL в факте |
|
||||
| Витрина пуста при непустом источнике | DM | Видно сразу, но причина непонятна |
|
||||
|
||||
Без валидационного DAG студент узнаёт о проблеме **через 2-3 слоя** — на этапе DM-витрин,
|
||||
где отладка многократно сложнее.
|
||||
|
||||
### Решение
|
||||
|
||||
**Отдельный DAG `bookings_validate`** — набор проверок, сгруппированных по слоям.
|
||||
Студент запускает его вручную (trigger в Airflow UI) после реализации заданий.
|
||||
Каждый таск — одна проверка, с дружелюбным сообщением об ошибке и подсказкой,
|
||||
что делать дальше.
|
||||
|
||||
### Педагогическая ценность
|
||||
|
||||
1. **Практика чтения логов Airflow** — студент учится находить ошибки в логах тасков
|
||||
2. **Инкрементальная обратная связь** — можно запускать после каждого шага, не дожидаясь
|
||||
реализации всех заданий
|
||||
3. **Паттерн DQ в пайплайне** — студент видит, как устроены production-проверки качества данных
|
||||
4. **Активный тест SCD2** — единственный способ убедиться, что SCD2-логика действительно
|
||||
работает (справочник `routes` в демо-базе статичен)
|
||||
|
||||
### Дизайн-решения
|
||||
|
||||
| Решение | Обоснование |
|
||||
|---------|-------------|
|
||||
| Отдельный DAG (не часть основного пайплайна) | Запускается по желанию, не блокирует основную загрузку |
|
||||
| `schedule=None` (ручной триггер) | Студент запускает, когда готов проверить свой код |
|
||||
| `PostgresOperator` + SQL-скрипты | Единый паттерн с основными DAG-ами |
|
||||
| SQL в `sql/validate/` | Отдельный каталог — валидация не смешивается с DQ пайплайна |
|
||||
| `DO $$ ... RAISE EXCEPTION ... $$` | Тот же паттерн, что в эталонных DQ-скриптах |
|
||||
| TaskGroup по слоям | Студент видит, на каком слое проблема |
|
||||
| Проверки «существует + не пуста» для DM | Минимально необходимо; бизнес-инварианты студент добавит сам в DQ витрин |
|
||||
|
||||
---
|
||||
|
||||
## Структура DAG
|
||||
|
||||
```
|
||||
bookings_validate
|
||||
├── validate_ods (TaskGroup)
|
||||
│ ├── check_ods_airplanes_rowcount
|
||||
│ ├── check_ods_seats_rowcount
|
||||
│ ├── check_ods_no_dup_bk
|
||||
│ └── check_ods_no_null_pks
|
||||
├── validate_dds (TaskGroup)
|
||||
│ ├── check_dim_airplanes_exists
|
||||
│ ├── check_dim_passengers_exists
|
||||
│ ├── check_dim_passengers_no_dup_bk
|
||||
│ ├── check_dim_routes_exists
|
||||
│ ├── check_dim_routes_scd2 ← активный тест!
|
||||
│ └── check_dim_routes_no_gaps
|
||||
└── validate_dm (TaskGroup)
|
||||
├── check_airport_traffic_exists
|
||||
├── check_route_performance_exists
|
||||
├── check_monthly_overview_exists
|
||||
└── check_passenger_loyalty_exists
|
||||
```
|
||||
|
||||
### Зависимости между группами
|
||||
|
||||
Нет жёстких зависимостей. Все три группы запускаются параллельно — студент может
|
||||
реализовать только ODS и увидеть зелёные чеки для `validate_ods`, пока DDS/DM ещё
|
||||
красные. Это даёт инкрементальную обратную связь.
|
||||
|
||||
Внутри каждой группы таски также параллельны (независимые проверки).
|
||||
|
||||
---
|
||||
|
||||
## Детализация проверок
|
||||
|
||||
### ODS: `check_ods_airplanes_rowcount`
|
||||
|
||||
**Файл:** `sql/validate/ods_airplanes_rowcount.sql`
|
||||
|
||||
**Логика:** ODS загружает один конкретный snapshot-батч из STG (по `_load_id`).
|
||||
STG — append-only: содержит историю всех загрузок. Поэтому сравнивать ODS
|
||||
со всей STG некорректно — нужно проверять точное множество BK для того батча,
|
||||
который ODS фактически загрузил.
|
||||
|
||||
Определяем батч: `_load_id` из `ods.airplanes` (единственный, т.к. TRUNCATE+INSERT).
|
||||
Затем проверяем, что множество BK в ODS = множество BK в STG для этого батча.
|
||||
|
||||
```sql
|
||||
DO $$
|
||||
DECLARE
|
||||
v_batch_count BIGINT;
|
||||
v_batch TEXT;
|
||||
v_ods_count BIGINT;
|
||||
v_missing_in_ods BIGINT;
|
||||
v_extra_in_ods BIGINT;
|
||||
BEGIN
|
||||
-- ODS пуста?
|
||||
SELECT COUNT(*) INTO v_ods_count FROM ods.airplanes;
|
||||
IF v_ods_count = 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: ods.airplanes пуста. Реализуйте загрузку: sql/ods/airplanes_load.sql';
|
||||
END IF;
|
||||
|
||||
-- Инвариант: ODS после TRUNCATE+INSERT содержит ровно один _load_id
|
||||
SELECT COUNT(DISTINCT _load_id) INTO v_batch_count FROM ods.airplanes;
|
||||
IF v_batch_count <> 1 THEN
|
||||
RAISE EXCEPTION 'FAILED: ods.airplanes содержит % разных _load_id (ожидается 1 после TRUNCATE+INSERT). Проверьте, что load начинается с TRUNCATE.', v_batch_count;
|
||||
END IF;
|
||||
|
||||
-- Определяем батч, из которого загружена ODS
|
||||
SELECT DISTINCT _load_id INTO v_batch FROM ods.airplanes;
|
||||
|
||||
-- BK есть в STG-батче, но нет в ODS (потеряны при загрузке)
|
||||
SELECT COUNT(*) INTO v_missing_in_ods
|
||||
FROM (
|
||||
SELECT DISTINCT airplane_code FROM stg.airplanes WHERE _load_id = v_batch
|
||||
) AS stg_bk
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ods.airplanes AS o WHERE o.airplane_code = stg_bk.airplane_code
|
||||
);
|
||||
|
||||
IF v_missing_in_ods > 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: % самолётов из STG-батча (%) отсутствуют в ods.airplanes. Проверьте логику TRUNCATE+INSERT.', v_missing_in_ods, v_batch;
|
||||
END IF;
|
||||
|
||||
-- BK есть в ODS, но нет в STG-батче (откуда взялись?)
|
||||
SELECT COUNT(*) INTO v_extra_in_ods
|
||||
FROM (
|
||||
SELECT DISTINCT airplane_code FROM ods.airplanes
|
||||
) AS ods_bk
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM stg.airplanes AS s WHERE s._load_id = v_batch AND s.airplane_code = ods_bk.airplane_code
|
||||
);
|
||||
|
||||
IF v_extra_in_ods > 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: % самолётов в ods.airplanes отсутствуют в STG-батче (%). Возможно, TRUNCATE не выполнился перед INSERT.', v_extra_in_ods, v_batch;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'PASSED: ods.airplanes содержит % самолётов, множество BK = STG-батч %', v_ods_count, v_batch;
|
||||
END $$;
|
||||
```
|
||||
|
||||
### ODS: `check_ods_seats_rowcount`
|
||||
|
||||
**Файл:** `sql/validate/ods_seats_rowcount.sql`
|
||||
|
||||
Аналогичная проверка по составному ключу `(airplane_code, seat_no)` —
|
||||
точное множество BK из STG-батча (по `_load_id` из `ods.seats`).
|
||||
|
||||
### ODS: `check_ods_no_dup_bk`
|
||||
|
||||
**Файл:** `sql/validate/ods_no_dup_bk.sql`
|
||||
|
||||
**Логика:** Проверить, что в ODS нет дублей по бизнес-ключу. Типичная ошибка
|
||||
студента — INSERT без предшествующего TRUNCATE, или TRUNCATE забыт при повторном
|
||||
запуске. Дубли по BK в ODS каскадно ломают DDS (лишние строки в измерениях).
|
||||
|
||||
```sql
|
||||
DO $$
|
||||
DECLARE
|
||||
v_dup_airplanes BIGINT;
|
||||
v_dup_seats BIGINT;
|
||||
BEGIN
|
||||
-- airplanes: BK = airplane_code
|
||||
SELECT COUNT(*) INTO v_dup_airplanes
|
||||
FROM (
|
||||
SELECT airplane_code FROM ods.airplanes
|
||||
GROUP BY airplane_code HAVING COUNT(*) > 1
|
||||
) AS d;
|
||||
|
||||
IF v_dup_airplanes > 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: ods.airplanes содержит % дублирующихся airplane_code. Проверьте, что load начинается с TRUNCATE.', v_dup_airplanes;
|
||||
END IF;
|
||||
|
||||
-- seats: BK = (airplane_code, seat_no)
|
||||
SELECT COUNT(*) INTO v_dup_seats
|
||||
FROM (
|
||||
SELECT airplane_code, seat_no FROM ods.seats
|
||||
GROUP BY airplane_code, seat_no HAVING COUNT(*) > 1
|
||||
) AS d;
|
||||
|
||||
IF v_dup_seats > 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: ods.seats содержит % дублирующихся (airplane_code, seat_no). Проверьте, что load начинается с TRUNCATE.', v_dup_seats;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'PASSED: Нет дублей по BK в ods.airplanes и ods.seats';
|
||||
END $$;
|
||||
```
|
||||
|
||||
### ODS: `check_ods_no_null_pks`
|
||||
|
||||
**Файл:** `sql/validate/ods_no_null_pks.sql`
|
||||
|
||||
**Логика:** Проверить, что PK-поля не содержат NULL в обеих студенческих ODS-таблицах.
|
||||
|
||||
```sql
|
||||
DO $$
|
||||
DECLARE
|
||||
v_null_airplanes BIGINT;
|
||||
v_null_seats BIGINT;
|
||||
BEGIN
|
||||
-- airplanes: PK = airplane_code
|
||||
SELECT COUNT(*) INTO v_null_airplanes
|
||||
FROM ods.airplanes WHERE airplane_code IS NULL;
|
||||
|
||||
IF v_null_airplanes > 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: ods.airplanes содержит % строк с NULL в airplane_code.', v_null_airplanes;
|
||||
END IF;
|
||||
|
||||
-- seats: PK = (airplane_code, seat_no)
|
||||
SELECT COUNT(*) INTO v_null_seats
|
||||
FROM ods.seats WHERE airplane_code IS NULL OR seat_no IS NULL;
|
||||
|
||||
IF v_null_seats > 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: ods.seats содержит % строк с NULL в PK (airplane_code, seat_no).', v_null_seats;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'PASSED: PK не содержат NULL в ods.airplanes и ods.seats';
|
||||
END $$;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DDS: `check_dim_airplanes_exists`
|
||||
|
||||
**Файл:** `sql/validate/dim_airplanes_exists.sql`
|
||||
|
||||
**Логика:**
|
||||
1. Таблица не пуста
|
||||
2. Нет дублей по `airplane_bk`
|
||||
3. Покрытие ODS: все `airplane_code` из `ods.airplanes` есть в `dds.dim_airplanes`
|
||||
|
||||
```sql
|
||||
DO $$
|
||||
DECLARE
|
||||
v_count BIGINT;
|
||||
v_dup BIGINT;
|
||||
v_missing BIGINT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_count FROM dds.dim_airplanes;
|
||||
IF v_count = 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: dds.dim_airplanes пуста. Реализуйте загрузку: sql/dds/dim_airplanes_load.sql';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) - COUNT(DISTINCT airplane_bk) INTO v_dup FROM dds.dim_airplanes;
|
||||
IF v_dup > 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: dds.dim_airplanes содержит % дублей по airplane_bk. Проверьте SCD1-логику (UPSERT).', v_dup;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_missing
|
||||
FROM (SELECT DISTINCT airplane_code FROM ods.airplanes) AS s
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM dds.dim_airplanes AS d WHERE d.airplane_bk = s.airplane_code
|
||||
);
|
||||
IF v_missing > 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: % самолётов из ods.airplanes отсутствуют в dds.dim_airplanes.', v_missing;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'PASSED: dds.dim_airplanes содержит % строк, покрывает все BK из ODS', v_count;
|
||||
END $$;
|
||||
```
|
||||
|
||||
### DDS: `check_dim_passengers_exists`
|
||||
|
||||
**Файл:** `sql/validate/dim_passengers_exists.sql`
|
||||
|
||||
Аналогичная структура: не пуста + нет дублей по `passenger_id` + покрытие
|
||||
`ods.tickets` (все уникальные `passenger_id` из тикетов есть в измерении).
|
||||
|
||||
### DDS: `check_dim_passengers_no_dup_bk`
|
||||
|
||||
**Файл:** `sql/validate/dim_passengers_no_dup_bk.sql`
|
||||
|
||||
Отдельная проверка дублей `passenger_id` — типичная ошибка студентов при SCD1:
|
||||
INSERT без проверки EXISTS создаёт дубли. Выделена в отдельный таск для ясности
|
||||
сообщения об ошибке.
|
||||
|
||||
**Примечание:** Эту проверку можно объединить с `check_dim_passengers_exists`.
|
||||
Отдельный таск оправдан, если хотим дать студенту более точную диагностику.
|
||||
Решение — на усмотрение реализатора.
|
||||
|
||||
### DDS: `check_dim_routes_exists`
|
||||
|
||||
**Файл:** `sql/validate/dim_routes_exists.sql`
|
||||
|
||||
Таблица не пуста + покрытие ODS (`route_no`) + **не более одной текущей версии
|
||||
на `route_bk`** (инвариант SCD2: `COUNT(*) WHERE valid_to IS NULL` <= 1 для каждого BK).
|
||||
|
||||
Типичная ошибка студента — INSERT без проверки `NOT EXISTS ... AND hashdiff = ...`,
|
||||
что создаёт дубли текущей версии. Активный SCD2-тест проверяет только один маршрут,
|
||||
эта проверка ловит проблему глобально.
|
||||
|
||||
```sql
|
||||
DO $$
|
||||
DECLARE
|
||||
v_count BIGINT;
|
||||
v_missing BIGINT;
|
||||
v_multi_current BIGINT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_count FROM dds.dim_routes;
|
||||
IF v_count = 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: dds.dim_routes пуста. Реализуйте загрузку: sql/dds/dim_routes_load.sql';
|
||||
END IF;
|
||||
|
||||
-- Покрытие ODS
|
||||
SELECT COUNT(*) INTO v_missing
|
||||
FROM (SELECT DISTINCT route_no FROM ods.routes) AS s
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM dds.dim_routes AS d WHERE d.route_bk = s.route_no
|
||||
);
|
||||
IF v_missing > 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: % маршрутов из ods.routes отсутствуют в dds.dim_routes.', v_missing;
|
||||
END IF;
|
||||
|
||||
-- Инвариант SCD2: не более одной текущей версии на route_bk
|
||||
SELECT COUNT(*) INTO v_multi_current
|
||||
FROM (
|
||||
SELECT route_bk FROM dds.dim_routes
|
||||
WHERE valid_to IS NULL
|
||||
GROUP BY route_bk HAVING COUNT(*) > 1
|
||||
) AS d;
|
||||
|
||||
IF v_multi_current > 0 THEN
|
||||
RAISE EXCEPTION E'FAILED: % маршрутов имеют более одной текущей версии (valid_to IS NULL).\n'
|
||||
'Подсказка: при INSERT новой версии проверяйте NOT EXISTS ... AND hashdiff = ...\n'
|
||||
'чтобы не создавать дубликат, если hashdiff не изменился.', v_multi_current;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'PASSED: dds.dim_routes содержит % строк, покрывает ODS, по одной текущей версии на маршрут', v_count;
|
||||
END $$;
|
||||
```
|
||||
|
||||
### DDS: `check_dim_routes_scd2` (АКТИВНЫЙ ТЕСТ)
|
||||
|
||||
**Файл:** `sql/validate/dim_routes_scd2.sql`
|
||||
|
||||
Это ключевая проверка плана. Справочник `bookings.routes` в демо-базе **статичен** —
|
||||
маршруты не меняются между запусками генератора. При обычном прогоне пайплайна
|
||||
студент **никогда не увидит**, как SCD2 закрывает старую версию и создаёт новую.
|
||||
|
||||
**Алгоритм активного теста:**
|
||||
|
||||
```
|
||||
1. Бэкап: CREATE TABLE _validate_bk_ods_routes AS SELECT * FROM ods.routes;
|
||||
CREATE TABLE _validate_bk_dim_routes AS SELECT * FROM dds.dim_routes;
|
||||
(обычные таблицы — TEMP не сохраняются между тасками Airflow)
|
||||
|
||||
2. Мутация: Выбрать один маршрут из ods.routes (WHERE departure_time IS NOT NULL LIMIT 1).
|
||||
Сохранить его route_no в служебную таблицу _validate_scd2_target.
|
||||
Обновить в ods.routes его departure_time на +1 час.
|
||||
|
||||
3. Запуск студенческого кода: PostgresOperator(sql="dds/dim_routes_load.sql")
|
||||
|
||||
4. Проверки (все 5):
|
||||
a) Ровно 2 версии тестового маршрута (было 1, стало 2)
|
||||
b) Старая версия закрыта: valid_to IS NOT NULL
|
||||
c) Ровно 1 открытая версия: valid_to IS NULL
|
||||
d) hashdiff старой ≠ hashdiff новой (мутация отразилась в хеше)
|
||||
e) Нет «дыры»: старая.valid_to = новая.valid_from
|
||||
|
||||
5. Откат (с проверкой существования бэкапов через to_regclass):
|
||||
IF _validate_bk_ods_routes exists: TRUNCATE ods.routes + INSERT FROM backup;
|
||||
IF _validate_bk_dim_routes exists: TRUNCATE dds.dim_routes + INSERT FROM backup;
|
||||
DROP TABLE IF EXISTS _validate_bk_*, _validate_scd2_target;
|
||||
```
|
||||
|
||||
**Реализация:** Этот таск **не может быть простым PostgresOperator** с одним SQL,
|
||||
потому что шаг 3 — это вызов студенческого SQL-скрипта *внутри* теста. Варианты:
|
||||
|
||||
**Выбранный подход: цепочка тасков.**
|
||||
- Jinja-шаблоны работают из коробки
|
||||
- Каждый шаг прозрачен в Airflow UI
|
||||
- `trigger_rule="all_done"` на restore гарантирует откат
|
||||
- Студент видит в UI, на каком именно шаге проблема
|
||||
|
||||
### DDS: `check_dim_routes_no_gaps`
|
||||
|
||||
**Файл:** `sql/validate/dim_routes_no_gaps.sql`
|
||||
|
||||
**Логика:** Для каждого `route_bk` с несколькими версиями проверить, что
|
||||
`valid_to` предыдущей версии = `valid_from` следующей (полуоткрытый интервал
|
||||
`[valid_from, valid_to)` без «дыр»).
|
||||
|
||||
**Важно:** SCD2-загрузка поддерживает «исчезнувшие» маршруты (`dim_routes_load.sql`,
|
||||
Statement 1.1): если маршрут пропал из ODS, его текущая версия закрывается
|
||||
(`valid_to = CURRENT_DATE`), но новая **не вставляется**. Это корректное поведение —
|
||||
у такого маршрута `valid_to IS NOT NULL` и `next_valid_from IS NULL`. Проверка
|
||||
должна считать «дырой» только случаи, когда следующая версия **существует**,
|
||||
но `valid_to ≠ next_valid_from`.
|
||||
|
||||
```sql
|
||||
DO $$
|
||||
DECLARE
|
||||
v_gaps BIGINT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_gaps
|
||||
FROM (
|
||||
SELECT route_bk, valid_to,
|
||||
LEAD(valid_from) OVER (PARTITION BY route_bk ORDER BY valid_from) AS next_valid_from
|
||||
FROM dds.dim_routes
|
||||
) AS t
|
||||
WHERE valid_to IS NOT NULL
|
||||
AND next_valid_from IS NOT NULL -- следующая версия существует (не «исчезнувший» маршрут)
|
||||
AND valid_to <> next_valid_from;
|
||||
|
||||
IF v_gaps > 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: В dds.dim_routes найдено % «дыр» между версиями SCD2. valid_to старой версии должен совпадать с valid_from новой.', v_gaps;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'PASSED: Нет «дыр» в SCD2-версиях dim_routes';
|
||||
END $$;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DM: `check_{vitrine}_exists` (4 таска)
|
||||
|
||||
**Файлы:**
|
||||
- `sql/validate/airport_traffic_exists.sql`
|
||||
- `sql/validate/route_performance_exists.sql`
|
||||
- `sql/validate/monthly_overview_exists.sql`
|
||||
- `sql/validate/passenger_loyalty_exists.sql`
|
||||
|
||||
**Логика:** Минимальная проверка — таблица не пуста + нет NULL в ключевых полях.
|
||||
|
||||
Пример для `airport_traffic`:
|
||||
|
||||
```sql
|
||||
DO $$
|
||||
DECLARE
|
||||
v_count BIGINT;
|
||||
v_null_pk BIGINT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_count FROM dm.airport_traffic;
|
||||
IF v_count = 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: dm.airport_traffic пуста. Реализуйте загрузку: sql/dm/airport_traffic_load.sql';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_null_pk
|
||||
FROM dm.airport_traffic
|
||||
WHERE traffic_date IS NULL OR airport_sk IS NULL;
|
||||
|
||||
IF v_null_pk > 0 THEN
|
||||
RAISE EXCEPTION 'FAILED: dm.airport_traffic содержит % строк с NULL в ключе (traffic_date, airport_sk).', v_null_pk;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'PASSED: dm.airport_traffic содержит % строк', v_count;
|
||||
END $$;
|
||||
```
|
||||
|
||||
Для `route_performance` ключ — `route_bk`, для `monthly_overview` — `(year_actual, month_actual, airplane_sk)`,
|
||||
для `passenger_loyalty` — `passenger_sk`.
|
||||
|
||||
---
|
||||
|
||||
## Файлы для создания
|
||||
|
||||
### SQL-скрипты (`sql/validate/`)
|
||||
|
||||
| # | Файл | Описание |
|
||||
|---|------|----------|
|
||||
| 1 | `ods_airplanes_rowcount.sql` | BK coverage: ODS vs STG-батч |
|
||||
| 2 | `ods_seats_rowcount.sql` | BK coverage: ODS vs STG-батч |
|
||||
| 3 | `ods_no_dup_bk.sql` | Нет дублей по BK в ODS (airplanes + seats) |
|
||||
| 4 | `ods_no_null_pks.sql` | NULL в PK обеих ODS-таблиц |
|
||||
| 5 | `dim_airplanes_exists.sql` | Не пуста + нет дублей BK + покрытие ODS |
|
||||
| 6 | `dim_passengers_exists.sql` | Не пуста + нет дублей BK + покрытие ODS |
|
||||
| 7 | `dim_passengers_no_dup_bk.sql` | Дубли `passenger_id` (отдельная диагностика) |
|
||||
| 8 | `dim_routes_exists.sql` | Не пуста + покрытие ODS + не более 1 текущей версии на BK |
|
||||
| 9 | `dim_routes_scd2_backup.sql` | Бэкап ods.routes + dds.dim_routes |
|
||||
| 10 | `dim_routes_scd2_mutate.sql` | Мутация тестового маршрута + сохранение route_no |
|
||||
| 11 | `dim_routes_scd2_check.sql` | 5 проверок SCD2 после загрузки |
|
||||
| 12 | `dim_routes_scd2_restore.sql` | Безопасный откат данных из бэкапа |
|
||||
| 13 | `dim_routes_no_gaps.sql` | Проверка SCD2-интервалов (без «исчезнувших») |
|
||||
| 14 | `airport_traffic_exists.sql` | Не пуста + NULL в ключе |
|
||||
| 15 | `route_performance_exists.sql` | Не пуста + NULL в ключе |
|
||||
| 16 | `monthly_overview_exists.sql` | Не пуста + NULL в ключе |
|
||||
| 17 | `passenger_loyalty_exists.sql` | Не пуста + NULL в ключе |
|
||||
|
||||
### DAG-файл
|
||||
|
||||
| # | Файл | Описание |
|
||||
|---|------|----------|
|
||||
| 18 | `airflow/dags/bookings_validate.py` | DAG с TaskGroup по слоям |
|
||||
|
||||
### Тест
|
||||
|
||||
| # | Файл | Описание |
|
||||
|---|------|----------|
|
||||
| 19 | Дополнение `tests/test_dags_smoke.py` | Smoke-тест структуры DAG |
|
||||
|
||||
---
|
||||
|
||||
## DAG-файл: `bookings_validate.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Валидационный DAG: самопроверка студенческих заданий.
|
||||
|
||||
Запускается вручную в Airflow UI после реализации заданий.
|
||||
Таски сгруппированы по слоям — студент видит, где именно проблема.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from logging import getLogger
|
||||
|
||||
import pendulum
|
||||
from airflow import DAG
|
||||
from airflow.providers.postgres.operators.postgres import PostgresOperator
|
||||
from airflow.utils.task_group import TaskGroup
|
||||
|
||||
GREENPLUM_CONN_ID = "greenplum_conn"
|
||||
log = getLogger(__name__)
|
||||
|
||||
default_args = {
|
||||
"owner": "airflow",
|
||||
"retries": 0, # Без ретраев — студент должен увидеть ошибку сразу
|
||||
"retry_delay": timedelta(seconds=10),
|
||||
}
|
||||
|
||||
with DAG(
|
||||
dag_id="bookings_validate",
|
||||
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
|
||||
schedule=None, # Только ручной запуск
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
template_searchpath="/sql",
|
||||
default_args=default_args,
|
||||
tags=["demo", "bookings", "greenplum", "validate"],
|
||||
description="Валидация студенческих заданий: ODS, DDS, DM",
|
||||
) as dag:
|
||||
|
||||
with TaskGroup("validate_ods") as validate_ods:
|
||||
check_ods_airplanes = PostgresOperator(
|
||||
task_id="check_ods_airplanes_rowcount",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/ods_airplanes_rowcount.sql",
|
||||
)
|
||||
check_ods_seats = PostgresOperator(
|
||||
task_id="check_ods_seats_rowcount",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/ods_seats_rowcount.sql",
|
||||
)
|
||||
check_ods_dup_bk = PostgresOperator(
|
||||
task_id="check_ods_no_dup_bk",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/ods_no_dup_bk.sql",
|
||||
)
|
||||
check_ods_pks = PostgresOperator(
|
||||
task_id="check_ods_no_null_pks",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/ods_no_null_pks.sql",
|
||||
)
|
||||
|
||||
with TaskGroup("validate_dds") as validate_dds:
|
||||
check_dim_airplanes = PostgresOperator(
|
||||
task_id="check_dim_airplanes_exists",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/dim_airplanes_exists.sql",
|
||||
)
|
||||
check_dim_passengers = PostgresOperator(
|
||||
task_id="check_dim_passengers_exists",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/dim_passengers_exists.sql",
|
||||
)
|
||||
check_dim_passengers_dup = PostgresOperator(
|
||||
task_id="check_dim_passengers_no_dup_bk",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/dim_passengers_no_dup_bk.sql",
|
||||
)
|
||||
check_dim_routes = PostgresOperator(
|
||||
task_id="check_dim_routes_exists",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/dim_routes_exists.sql",
|
||||
)
|
||||
|
||||
# SCD2 активный тест: цепочка backup → mutate → load → check → restore
|
||||
scd2_backup = PostgresOperator(
|
||||
task_id="scd2_backup",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/dim_routes_scd2_backup.sql",
|
||||
)
|
||||
scd2_mutate = PostgresOperator(
|
||||
task_id="scd2_mutate",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/dim_routes_scd2_mutate.sql",
|
||||
)
|
||||
scd2_run_load = PostgresOperator(
|
||||
task_id="scd2_run_student_load",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="dds/dim_routes_load.sql", # студенческий load-скрипт!
|
||||
)
|
||||
scd2_check = PostgresOperator(
|
||||
task_id="scd2_check",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/dim_routes_scd2_check.sql",
|
||||
)
|
||||
scd2_restore = PostgresOperator(
|
||||
task_id="scd2_restore",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/dim_routes_scd2_restore.sql",
|
||||
trigger_rule="all_done", # Откат ВСЕГДА, даже если check упал
|
||||
)
|
||||
|
||||
check_dim_routes_gaps = PostgresOperator(
|
||||
task_id="check_dim_routes_no_gaps",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/dim_routes_no_gaps.sql",
|
||||
)
|
||||
|
||||
# SCD2 цепочка
|
||||
scd2_backup >> scd2_mutate >> scd2_run_load >> scd2_check >> scd2_restore
|
||||
|
||||
# no_gaps запускается после restore (на чистых данных)
|
||||
scd2_restore >> check_dim_routes_gaps
|
||||
|
||||
with TaskGroup("validate_dm") as validate_dm:
|
||||
check_airport_traffic = PostgresOperator(
|
||||
task_id="check_airport_traffic_exists",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/airport_traffic_exists.sql",
|
||||
)
|
||||
check_route_performance = PostgresOperator(
|
||||
task_id="check_route_performance_exists",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/route_performance_exists.sql",
|
||||
)
|
||||
check_monthly_overview = PostgresOperator(
|
||||
task_id="check_monthly_overview_exists",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/monthly_overview_exists.sql",
|
||||
)
|
||||
check_passenger_loyalty = PostgresOperator(
|
||||
task_id="check_passenger_loyalty_exists",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="validate/passenger_loyalty_exists.sql",
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Активный тест SCD2: детализация SQL
|
||||
|
||||
### `dim_routes_scd2_backup.sql`
|
||||
|
||||
```sql
|
||||
-- Бэкап текущего состояния перед тестом SCD2.
|
||||
-- Используем обычные таблицы (не TEMP) — между тасками Airflow
|
||||
-- TEMP-таблицы не сохраняются (каждый таск = отдельная транзакция).
|
||||
|
||||
DROP TABLE IF EXISTS _validate_bk_ods_routes;
|
||||
CREATE TABLE _validate_bk_ods_routes AS SELECT * FROM ods.routes;
|
||||
|
||||
DROP TABLE IF EXISTS _validate_bk_dim_routes;
|
||||
CREATE TABLE _validate_bk_dim_routes AS SELECT * FROM dds.dim_routes;
|
||||
|
||||
-- Cleanup служебной таблицы от предыдущего запуска (на случай если restore не доехал)
|
||||
DROP TABLE IF EXISTS _validate_scd2_target;
|
||||
```
|
||||
|
||||
### `dim_routes_scd2_mutate.sql`
|
||||
|
||||
```sql
|
||||
-- Мутация: сдвигаем departure_time у одного маршрута на 1 час.
|
||||
-- Это должно изменить hashdiff → SCD2 должен закрыть старую версию.
|
||||
--
|
||||
-- Сохраняем route_no тестового маршрута в служебную таблицу _validate_scd2_target,
|
||||
-- чтобы check-скрипт точно знал, какой маршрут проверять (а не угадывал по побочным эффектам).
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_route TEXT;
|
||||
v_old_time TIME;
|
||||
BEGIN
|
||||
-- Берём первый маршрут, у которого departure_time заполнен
|
||||
SELECT route_no, departure_time
|
||||
INTO v_route, v_old_time
|
||||
FROM ods.routes
|
||||
WHERE departure_time IS NOT NULL
|
||||
ORDER BY route_no
|
||||
LIMIT 1;
|
||||
|
||||
IF v_route IS NULL THEN
|
||||
RAISE EXCEPTION 'FAILED: ods.routes пуста или нет маршрутов с departure_time. Загрузите STG→ODS перед проверкой.';
|
||||
END IF;
|
||||
|
||||
-- Запоминаем тестовый маршрут в служебную таблицу
|
||||
DROP TABLE IF EXISTS _validate_scd2_target;
|
||||
CREATE TABLE _validate_scd2_target AS
|
||||
SELECT v_route AS route_no;
|
||||
|
||||
-- Сдвигаем время на 1 час у всех записей этого маршрута
|
||||
UPDATE ods.routes
|
||||
SET departure_time = departure_time + INTERVAL '1 hour'
|
||||
WHERE route_no = v_route;
|
||||
|
||||
RAISE NOTICE 'SCD2 TEST: маршрут % — departure_time сдвинут с % на %',
|
||||
v_route, v_old_time, v_old_time + INTERVAL '1 hour';
|
||||
END $$;
|
||||
```
|
||||
|
||||
### `dim_routes_scd2_check.sql`
|
||||
|
||||
```sql
|
||||
-- Проверяем, что SCD2-логика студента сработала корректно.
|
||||
-- Читаем route_no тестового маршрута из служебной таблицы _validate_scd2_target
|
||||
-- (создана на шаге mutate), а не угадываем по побочным эффектам.
|
||||
DO $$
|
||||
DECLARE
|
||||
v_route TEXT;
|
||||
v_version_count BIGINT;
|
||||
v_closed_count BIGINT;
|
||||
v_open_count BIGINT;
|
||||
v_old_hash TEXT;
|
||||
v_new_hash TEXT;
|
||||
v_gap_count BIGINT;
|
||||
BEGIN
|
||||
-- Читаем тестовый маршрут из служебной таблицы
|
||||
SELECT route_no INTO v_route FROM _validate_scd2_target LIMIT 1;
|
||||
|
||||
IF v_route IS NULL THEN
|
||||
RAISE EXCEPTION 'FAILED: служебная таблица _validate_scd2_target пуста. Шаг mutate не выполнился?';
|
||||
END IF;
|
||||
|
||||
-- Если dim_routes пуста — load не запустился
|
||||
IF NOT EXISTS (SELECT 1 FROM dds.dim_routes WHERE route_bk = v_route) THEN
|
||||
RAISE EXCEPTION E'FAILED: dds.dim_routes не содержит маршрут % после запуска load.\n'
|
||||
'Проверьте sql/dds/dim_routes_load.sql.', v_route;
|
||||
END IF;
|
||||
|
||||
-- Проверка a: Ровно 2 версии тестового маршрута (было 1, стало 2 после мутации)
|
||||
SELECT COUNT(*) INTO v_version_count
|
||||
FROM dds.dim_routes WHERE route_bk = v_route;
|
||||
|
||||
IF v_version_count < 2 THEN
|
||||
RAISE EXCEPTION E'FAILED: Маршрут % — найдена % версия (ожидается 2: старая закрытая + новая открытая).\n'
|
||||
'SCD2 должен был создать новую версию после изменения departure_time.', v_route, v_version_count;
|
||||
END IF;
|
||||
|
||||
IF v_version_count > 2 THEN
|
||||
RAISE EXCEPTION E'FAILED: Маршрут % — найдено % версий (ожидается 2).\n'
|
||||
'Возможно, load создаёт лишние дубликаты. Проверьте условие NOT EXISTS при INSERT.', v_route, v_version_count;
|
||||
END IF;
|
||||
|
||||
-- Проверка b: Старая версия закрыта (valid_to IS NOT NULL)
|
||||
SELECT COUNT(*) INTO v_closed_count
|
||||
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NOT NULL;
|
||||
|
||||
IF v_closed_count = 0 THEN
|
||||
RAISE EXCEPTION E'FAILED: Маршрут % — SCD2 не закрыл старую версию (valid_to IS NULL у всех версий).\n'
|
||||
'Подсказка: hashdiff изменился (departure_time сдвинут на 1 час),\n'
|
||||
'но ваш load-скрипт не обнаружил это изменение.\n'
|
||||
'Проверьте:\n'
|
||||
' 1. Формулу hashdiff — включает ли она departure_time?\n'
|
||||
' 2. Логику сравнения hashdiff (UPDATE ... SET valid_to = CURRENT_DATE WHERE hashdiff <> новый_hashdiff)', v_route;
|
||||
END IF;
|
||||
|
||||
-- Проверка c: Новая версия открыта (valid_to IS NULL)
|
||||
SELECT COUNT(*) INTO v_open_count
|
||||
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NULL;
|
||||
|
||||
IF v_open_count <> 1 THEN
|
||||
RAISE EXCEPTION E'FAILED: Маршрут % — ожидается ровно 1 открытая версия (valid_to IS NULL), найдено %.\n'
|
||||
'Подсказка: SCD2 должен вставить новую строку с valid_to = NULL.', v_route, v_open_count;
|
||||
END IF;
|
||||
|
||||
-- Проверка d: hashdiff старой ≠ hashdiff новой (мутация действительно отразилась)
|
||||
SELECT hashdiff INTO v_old_hash
|
||||
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NOT NULL
|
||||
ORDER BY valid_from DESC LIMIT 1;
|
||||
|
||||
SELECT hashdiff INTO v_new_hash
|
||||
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NULL;
|
||||
|
||||
IF v_old_hash = v_new_hash THEN
|
||||
RAISE EXCEPTION E'FAILED: Маршрут % — hashdiff старой и новой версий совпадают.\n'
|
||||
'Мутация сдвинула departure_time на 1 час, но hashdiff не изменился.\n'
|
||||
'Проверьте, что departure_time входит в формулу hashdiff.', v_route;
|
||||
END IF;
|
||||
|
||||
-- Проверка e: Нет «дыры» между valid_to старой и valid_from новой
|
||||
SELECT COUNT(*) INTO v_gap_count
|
||||
FROM dds.dim_routes AS old_v
|
||||
JOIN dds.dim_routes AS new_v
|
||||
ON old_v.route_bk = new_v.route_bk
|
||||
WHERE old_v.route_bk = v_route
|
||||
AND old_v.valid_to IS NOT NULL
|
||||
AND new_v.valid_to IS NULL
|
||||
AND old_v.valid_to <> new_v.valid_from;
|
||||
|
||||
IF v_gap_count > 0 THEN
|
||||
RAISE EXCEPTION E'FAILED: Маршрут % — «дыра» между версиями:\n'
|
||||
'valid_to старой ≠ valid_from новой.\n'
|
||||
'Подсказка: полуоткрытый интервал [valid_from, valid_to).\n'
|
||||
'valid_from новой версии должен = valid_to старой (обычно CURRENT_DATE).', v_route;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'PASSED: SCD2 корректен для маршрута %. 2 версии, hashdiff различаются, «дыр» нет.', v_route;
|
||||
END $$;
|
||||
```
|
||||
|
||||
### `dim_routes_scd2_restore.sql`
|
||||
|
||||
```sql
|
||||
-- Откат данных после теста SCD2.
|
||||
-- Выполняется ВСЕГДА (trigger_rule="all_done"), даже если check упал.
|
||||
--
|
||||
-- Безопасность: если backup-шаг не создал таблицы (сбой на backup),
|
||||
-- откат НЕ трогает live-данные — просто чистит служебные таблицы.
|
||||
-- Это гарантирует, что restore никогда не сломает ods.routes / dds.dim_routes.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_has_ods_backup BOOLEAN;
|
||||
v_has_dim_backup BOOLEAN;
|
||||
BEGIN
|
||||
-- Проверяем существование backup-таблиц через to_regclass
|
||||
-- (ищет по search_path — совпадает с тем, как CREATE TABLE их создал)
|
||||
v_has_ods_backup := to_regclass('_validate_bk_ods_routes') IS NOT NULL;
|
||||
v_has_dim_backup := to_regclass('_validate_bk_dim_routes') IS NOT NULL;
|
||||
|
||||
-- Восстанавливаем ods.routes только если бэкап существует
|
||||
IF v_has_ods_backup THEN
|
||||
TRUNCATE ods.routes;
|
||||
INSERT INTO ods.routes SELECT * FROM _validate_bk_ods_routes;
|
||||
RAISE NOTICE 'RESTORE: ods.routes восстановлена из бэкапа';
|
||||
ELSE
|
||||
RAISE NOTICE 'RESTORE: бэкап ods.routes не найден — пропускаем (backup-шаг не завершился?)';
|
||||
END IF;
|
||||
|
||||
-- Восстанавливаем dds.dim_routes только если бэкап существует
|
||||
IF v_has_dim_backup THEN
|
||||
TRUNCATE dds.dim_routes;
|
||||
INSERT INTO dds.dim_routes SELECT * FROM _validate_bk_dim_routes;
|
||||
RAISE NOTICE 'RESTORE: dds.dim_routes восстановлена из бэкапа';
|
||||
ELSE
|
||||
RAISE NOTICE 'RESTORE: бэкап dds.dim_routes не найден — пропускаем';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Cleanup служебных таблиц (безусловно, IF EXISTS)
|
||||
DROP TABLE IF EXISTS _validate_bk_ods_routes;
|
||||
DROP TABLE IF EXISTS _validate_bk_dim_routes;
|
||||
DROP TABLE IF EXISTS _validate_scd2_target;
|
||||
|
||||
-- Учебный комментарий: Мы восстанавливаем данные из бэкапа, чтобы тест
|
||||
-- не оставлял «мусорных» версий в dim_routes. Это стандартный паттерн
|
||||
-- для интеграционных тестов: setup → act → assert → teardown.
|
||||
-- IF EXISTS проверки гарантируют, что restore безопасен при любом сценарии сбоя.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Smoke-тест DAG (дополнение `test_dags_smoke.py`)
|
||||
|
||||
Добавить новый тест-класс:
|
||||
|
||||
```python
|
||||
class TestBookingsValidate:
|
||||
"""Smoke-тесты DAG bookings_validate."""
|
||||
|
||||
def test_dag_loads(self):
|
||||
dag = _load_dag("airflow.dags.bookings_validate")
|
||||
assert dag is not None
|
||||
|
||||
def test_expected_tasks(self):
|
||||
dag = _load_dag("airflow.dags.bookings_validate")
|
||||
expected = {
|
||||
# ODS
|
||||
"validate_ods.check_ods_airplanes_rowcount",
|
||||
"validate_ods.check_ods_seats_rowcount",
|
||||
"validate_ods.check_ods_no_dup_bk",
|
||||
"validate_ods.check_ods_no_null_pks",
|
||||
# DDS
|
||||
"validate_dds.check_dim_airplanes_exists",
|
||||
"validate_dds.check_dim_passengers_exists",
|
||||
"validate_dds.check_dim_passengers_no_dup_bk",
|
||||
"validate_dds.check_dim_routes_exists",
|
||||
"validate_dds.scd2_backup",
|
||||
"validate_dds.scd2_mutate",
|
||||
"validate_dds.scd2_run_student_load",
|
||||
"validate_dds.scd2_check",
|
||||
"validate_dds.scd2_restore",
|
||||
"validate_dds.check_dim_routes_no_gaps",
|
||||
# DM
|
||||
"validate_dm.check_airport_traffic_exists",
|
||||
"validate_dm.check_route_performance_exists",
|
||||
"validate_dm.check_monthly_overview_exists",
|
||||
"validate_dm.check_passenger_loyalty_exists",
|
||||
}
|
||||
assert expected.issubset(dag.task_dict.keys())
|
||||
|
||||
def test_scd2_chain(self):
|
||||
"""SCD2 цепочка backup → mutate → load → check → restore."""
|
||||
dag = _load_dag("airflow.dags.bookings_validate")
|
||||
_assert_direct_edge(dag, "validate_dds.scd2_backup", "validate_dds.scd2_mutate")
|
||||
_assert_direct_edge(dag, "validate_dds.scd2_mutate", "validate_dds.scd2_run_student_load")
|
||||
_assert_direct_edge(dag, "validate_dds.scd2_run_student_load", "validate_dds.scd2_check")
|
||||
_assert_direct_edge(dag, "validate_dds.scd2_check", "validate_dds.scd2_restore")
|
||||
|
||||
def test_no_gaps_after_restore(self):
|
||||
"""no_gaps должен выполняться после restore (на чистых данных)."""
|
||||
dag = _load_dag("airflow.dags.bookings_validate")
|
||||
_assert_direct_edge(dag, "validate_dds.scd2_restore", "validate_dds.check_dim_routes_no_gaps")
|
||||
|
||||
def test_restore_trigger_rule(self):
|
||||
"""restore должен выполняться всегда (all_done), даже если check упал."""
|
||||
dag = _load_dag("airflow.dags.bookings_validate")
|
||||
restore_task = dag.task_dict["validate_dds.scd2_restore"]
|
||||
assert restore_task.trigger_rule == "all_done"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
1. Создать каталог `sql/validate/`
|
||||
2. Написать SQL-скрипты (17 файлов) — начать с простых (ODS, DM), затем DDS, затем SCD2
|
||||
3. Написать DAG `bookings_validate.py`
|
||||
4. Дополнить `tests/test_dags_smoke.py`
|
||||
5. `make test` — smoke-тесты проходят
|
||||
6. Ручная проверка на стенде (если поднят):
|
||||
- Trigger DAG в Airflow UI
|
||||
- Все ODS/DDS/DM зелёные (на solution-ветке)
|
||||
- SCD2 активный тест: backup → mutate → load → check (зелёный) → restore
|
||||
7. Коммит
|
||||
|
||||
---
|
||||
|
||||
## Верификация
|
||||
|
||||
### Автоматическая
|
||||
|
||||
```bash
|
||||
make test # smoke-тест DAG-структуры
|
||||
```
|
||||
|
||||
### Ручная (на стенде)
|
||||
|
||||
1. `make up && make ddl-gp` → запустить STG → ODS → DDS → DM
|
||||
2. Trigger `bookings_validate` в Airflow UI
|
||||
3. Проверить: все таски зелёные
|
||||
4. Проверить SCD2: в логах `scd2_check` видно `PASSED: SCD2 корректен для маршрута ...`
|
||||
5. Проверить restore: `ods.routes` и `dds.dim_routes` не изменились после теста
|
||||
|
||||
### Сценарий «студент ещё не реализовал»
|
||||
|
||||
На main-ветке (с DDL-заглушками):
|
||||
- ODS-таски: FAILED (таблицы пусты) → дружелюбное сообщение
|
||||
- DDS-таски: FAILED → сообщение «Реализуйте загрузку»
|
||||
- DM-таски: FAILED → сообщение «Реализуйте загрузку»
|
||||
- SCD2-тест: `scd2_run_student_load` пройдёт (заглушка `SELECT 1;` — валидный SQL),
|
||||
но `scd2_check` упадёт (dim_routes не обновилась, версий < 2)
|
||||
→ `scd2_restore` всё равно выполнится (`trigger_rule="all_done"`)
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
1. **`dim_passengers_no_dup_bk` — отдельный таск или объединить с `exists`?**
|
||||
Отдельный таск даёт точнее диагностику, но увеличивает число тасков.
|
||||
Рекомендация: объединить проверки в один файл `dim_passengers_exists.sql`
|
||||
(как сделано для `dim_airplanes_exists`).
|
||||
|
||||
2. **Нужна ли проверка `total_seats` в `dim_airplanes`?**
|
||||
`total_seats` вычисляется агрегацией из `ods.seats`. Если студент забудет
|
||||
этот JOIN — поле будет NULL. Можно добавить `SELECT COUNT(*) WHERE total_seats IS NULL`.
|
||||
Рекомендация: добавить в `dim_airplanes_exists.sql`.
|
||||
|
||||
3. **Бэкап SCD2: обычные таблицы vs TEMP?**
|
||||
Каждый таск Airflow — отдельная транзакция → TEMP-таблицы не сохраняются.
|
||||
Используем обычные таблицы с префиксом `_validate_bk_`. Риск: если DAG упадёт
|
||||
между backup и restore, таблицы останутся. Cleanup: `scd2_restore` делает
|
||||
`DROP TABLE IF EXISTS`, поэтому при следующем запуске проблем не будет.
|
||||
|
||||
---
|
||||
|
||||
## Ключевые файлы
|
||||
|
||||
| Файл | Роль |
|
||||
|------|------|
|
||||
| `airflow/dags/bookings_validate.py` | DAG: TaskGroup по слоям |
|
||||
| `sql/validate/*.sql` | 17 SQL-скриптов проверок |
|
||||
| `sql/dds/dim_routes_load.sql` | Студенческий load (вызывается из SCD2-теста) |
|
||||
| `tests/test_dags_smoke.py` | Smoke-тесты DAG-структуры |
|
||||
| `docs/design/assignment_design.md` | Дизайн (секция 4 — источник требований) |
|
||||
@@ -0,0 +1,34 @@
|
||||
# План: Онбординг студента + маркетинг (v4)
|
||||
|
||||
> Статус: выполнен (2026-03-13)
|
||||
|
||||
## Контекст
|
||||
|
||||
Ментор опционален. Студент, клонировав main, проходит «Быстрый старт» — и застревает:
|
||||
нет явного «что дальше», ссылка на задания спрятана, validate DAG не объяснён, нет маркетинга.
|
||||
На main есть противоречия: docs и DAG-docstrings говорят «все реализовано»,
|
||||
фактически стоят заглушки `SELECT 1;`.
|
||||
|
||||
## Что было сделано
|
||||
|
||||
### На main (10 файлов)
|
||||
|
||||
1. **README.md** — маркетинг-баннер, шаг 6 (только эталонные таблицы), шаг 7 → задания.
|
||||
2. **docs/assignment/README.md** — полный гид студента (эталон → ТЗ → заглушки → validate).
|
||||
3. **docs/assignment/analyst_spec.md** — DAG-интеграция: «таски уже подключены, DAG менять не нужно».
|
||||
4. **docs/bookings_to_gp_dm.md** — пометки заглушек, адаптация секций проверки.
|
||||
5. **docs/bookings_to_gp_ods.md** — пометки заглушек airplanes/seats.
|
||||
6. **docs/bookings_to_gp_dds.md** — пометки заглушек dim_routes/dim_passengers/dim_airplanes.
|
||||
7-10. **4 DAG docstrings** — эталон vs задания (заглушки).
|
||||
|
||||
### На solution (follow-up)
|
||||
|
||||
- README.md — маркетинг-баннер + шаг 7 (solution-specific: «Изучите готовую реализацию»).
|
||||
- docs/assignment/README.md — навигационный гид по всем паттернам (ODS → DDS → DM).
|
||||
- Этот план архивирован в docs/archive/.
|
||||
|
||||
## Верификация
|
||||
|
||||
- `make test` — passed на обеих ветках.
|
||||
- `make lint` — clean на обеих ветках.
|
||||
- Grep «заглушки/SELECT 1» в README.md и docs/assignment/README.md на solution — не найдено.
|
||||
@@ -0,0 +1,304 @@
|
||||
# Ревью архитектуры слоёв DWH: оценка учебной ценности
|
||||
|
||||
> Дата: 2026-03-01 (ревью), 2026-03-11 (закрытие)
|
||||
> Статус: **завершён** — P0/P1/P2 выполнены, P3 отложены (покрыты другими документами)
|
||||
> Контекст: оценка текущей конструкции слоёв с точки зрения учебных целей
|
||||
|
||||
## Context
|
||||
|
||||
Стенд — курсовая работа и эталон для менти-джунов. Они понесут эти паттерны на свою первую работу. Оцениваем по двум осям: **production-ready** (чтобы не стыдно было показать на собеседовании) и **KISS** (чтобы джун не утонул в сложности).
|
||||
|
||||
Текущее состояние: 95 SQL-файлов, 5 слоёв (STG→ODS→DDS→DM), 9 DAG-ов, полная Star Schema с SCD2, DQ на каждом шаге. Реализована 1 из 5 витрин DM.
|
||||
|
||||
---
|
||||
|
||||
## СИЛЬНЫЕ СТОРОНЫ (что уже отлично)
|
||||
|
||||
### 1. Паттерн load → DQ на каждом шаге — эталонный
|
||||
Каждая сущность в каждом слое имеет тройку файлов `_ddl.sql` / `_load.sql` / `_dq.sql`. DAG-и обеспечивают порядок load→dq→next. Smoke-тесты проверяют рёбра графа. Студенты усвоят: **DQ — не опция, а часть пайплайна**.
|
||||
|
||||
### 2. UPSERT через UPDATE + INSERT — production-grade для Greenplum
|
||||
Не DELETE+INSERT (дорого на AO-таблицах), не MERGE (нет в GP6). `IS DISTINCT FROM` для null-safe сравнения — деталь, которую даже опытные инженеры забывают.
|
||||
|
||||
### 3. Антипаттерн-обучение в DM (distribution by date)
|
||||
Комментарий в `dm/sales_report_ddl.sql` объясняет **почему** нельзя распределять по дате, с конкретными причинами (Load Skew, Processing Skew). Это «почему нет» — именно то, что не дают учебники.
|
||||
|
||||
### 4. SCD2 в dim_routes — полный и корректный
|
||||
Все три кейса: закрытие изменённых версий (hashdiff), закрытие исчезнувших маршрутов, вставка новых версий с правильной логикой valid_from. DQ проверяет пересечение интервалов. Готовый reference implementation.
|
||||
|
||||
### 5. Point-in-time lookup в факте — ключевой навык
|
||||
```sql
|
||||
LEFT JOIN dds.dim_routes AS rte
|
||||
ON rte.route_bk = flt.route_no
|
||||
AND flt.scheduled_departure::DATE >= rte.valid_from
|
||||
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
|
||||
```
|
||||
Многие продакшн-DWH ошибаются, присоединяя только текущую версию.
|
||||
|
||||
### 6. HWM-инкрементальность в DM — самовосстанавливающийся пайплайн
|
||||
`MAX(_load_ts)` + TEMP TABLE для однократной агрегации — канон MPP. Пайплайн сам «догоняет» пропущенные дни.
|
||||
|
||||
### 7. DAG-графы корректно отражают зависимости данных
|
||||
Параллельность airports/airplanes, gates на routes (нужны оба), факт после всех измерений. Smoke-тесты проверяют и наличие, и **отсутствие** рёбер (параллельность).
|
||||
|
||||
### 8. ANALYZE после каждой загрузки
|
||||
GP-специфичная best practice, которую забывают даже опытные команды.
|
||||
|
||||
### 9. Идемпотентные STG-загрузки
|
||||
`NOT EXISTS (... WHERE _load_id = '{{ run_id }}')` — простой, корректный, понятный паттерн для retry-safe загрузок.
|
||||
|
||||
---
|
||||
|
||||
## ЗАМЕЧАНИЯ И ЗАДАЧИ ДЛЯ ДОРАБОТКИ
|
||||
|
||||
### P0: Фактическая ошибка (исправить до показа студентам)
|
||||
|
||||
- [x] **ODS batch resolver теряет данные при двух STG-запусках подряд**
|
||||
- Сценарий: STG run_1 загружает день N, STG run_2 загружает день N+1, затем ODS запускается
|
||||
- `_resolve_stg_batch_id()` выбирает только последний согласованный batch (`run_2`)
|
||||
- Все ODS load-скрипты фильтруют `WHERE _load_id = 'run_2'` → данные `run_1` навсегда пропущены
|
||||
- **Справочники** (airports, airplanes, routes, seats): проблемы нет — full snapshot, `run_2` содержит всё
|
||||
- **Транзакционные таблицы** (bookings, tickets, flights, segments, boarding_passes): **потеря данных** — инкрементальные записи `run_1` никогда не попадут в ODS
|
||||
- Корень проблемы: batch resolver проектировался для согласованности справочников (INTERSECT), но тот же single-batch фильтр применяется к транзакционным таблицам, где нужны **все необработанные** batch-и
|
||||
- **Нужно**: разделить логику — для транзакционных таблиц загружать все batch-и с `_load_ts > MAX(_load_ts в ODS)` (аналог HWM из DM), для справочников — по-прежнему последний согласованный
|
||||
- Файлы: `airflow/dags/bookings_to_gp_ods.py`, `sql/ods/bookings_load.sql`, `sql/ods/tickets_load.sql`, `sql/ods/flights_load.sql`, `sql/ods/segments_load.sql`, `sql/ods/boarding_passes_load.sql`
|
||||
|
||||
- [x] **Противоречие в distribution key для airport_traffic**
|
||||
- `bookings_dm_design.md` (строка 182): `DISTRIBUTED BY (traffic_date)`
|
||||
- `sales_report_ddl.sql`: явно объясняет, почему distribution by date — антипаттерн
|
||||
- **Нужно**: исправить на `DISTRIBUTED BY (airport_sk)` в дизайн-документе
|
||||
- Файл: `docs/design/bookings_dm_design.md`
|
||||
|
||||
### P1: Высокий эффект, минимум усилий (комментарии и документация)
|
||||
|
||||
- [x] **Нет объяснения «почему не SERIAL» в генерации SK**
|
||||
- `MAX(sk) + ROW_NUMBER()` корректен для GP, но студент на PostgreSQL/Snowflake будет использовать `IDENTITY`/`SEQUENCE`
|
||||
- **Нужно**: 4-строчный комментарий в `sql/dds/dim_airports_load.sql`
|
||||
|
||||
- [x] **Факт без суррогатного ключа — не объяснено «почему»**
|
||||
- Натуральный (ticket_no, flight_id) как grain — правильное Kimball-моделирование
|
||||
- **Нужно**: комментарий в `sql/dds/fact_flight_sales_ddl.sql`
|
||||
|
||||
- [x] **Нет упоминания cross-DAG зависимостей**
|
||||
- STG, ODS, DDS, DM — отдельные DAG-и с `schedule=None`, студент может не понять порядок
|
||||
- **Нужно**: комментарий в docstring каждого DAG или `docs/dag_execution_order.md`
|
||||
|
||||
- [x] **Late-arriving dimensions не упомянуты**
|
||||
- Факт делает LEFT JOIN → `passenger_sk = NULL` при опоздании; нет механизма исправления
|
||||
- **Нужно**: комментарий в `sql/dds/fact_flight_sales_load.sql` у LEFT JOIN-ов
|
||||
|
||||
- [x] **Batch resolver недообъяснён**
|
||||
- `_resolve_stg_batch_id` с INTERSECT по 4 таблицам — нет комментария **зачем** нужна согласованность
|
||||
- **Нужно**: комментарий в `airflow/dags/bookings_to_gp_ods.py` перед SQL-запросом
|
||||
|
||||
- [x] **`helpers/greenplum.py`** — удалён вместе с CSV-пайплайном (перенесён в airflow-manual)
|
||||
|
||||
### P2: Средние усилия, заметное улучшение качества
|
||||
|
||||
- [x] **Явный storage type для всех таблиц + AO где возможно** ✅ РЕШЕНИЕ ПРИНЯТО
|
||||
- 18 из 28 таблиц имели неявный heap (нет `WITH`) — теперь выбор сделан явно
|
||||
- **Целевая раскладка по storage:**
|
||||
- **AO Row + zstd**: `dds.dim_calendar` (узкая таблица, column-store не даёт выигрыша)
|
||||
- **AO Row + zstd**: ODS snapshot-справочники (`airports`, `airplanes`, `routes`, `seats`)
|
||||
— перевести загрузку с UPSERT на TRUNCATE+INSERT (честнее для full snapshot семантики)
|
||||
- **AO Row + zstd**: `dds.dim_tariffs` (только INSERT, нет UPDATE)
|
||||
- **AO Row + zstd**: `dm.route_performance` (full rebuild, по дизайну)
|
||||
- **Heap (явный)**: ODS транзакционные (`bookings`, `tickets`, `flights`, `segments`,
|
||||
`boarding_passes`) — row-level UPDATE при SCD1 UPSERT
|
||||
- **Heap (явный)**: DDS измерения с UPDATE (`dim_airports`, `dim_airplanes`,
|
||||
`dim_passengers`, `dim_routes`) и `fact_flight_sales`
|
||||
- **Heap (явный)**: DM витрины с UPSERT (`sales_report` и будущие HWM-витрины)
|
||||
- К каждой таблице добавить комментарий, объясняющий выбор storage type
|
||||
- Файлы: все `*_ddl.sql` в ods/, dds/, dm/ + переписать 4 ODS snapshot load-скрипта
|
||||
- См. ADR-3
|
||||
|
||||
- [x] **Дублирование hashdiff CTE в dim_routes_load.sql**
|
||||
- md5(COALESCE(...)) повторяется в Statement 1 и Statement 2, ROW_NUMBER() — 3 раза
|
||||
- **Решение**: вынесено в CREATE TEMP TABLE tmp_routes_src ON COMMIT DROP ✅ ВЫПОЛНЕНО
|
||||
- Файл: `sql/dds/dim_routes_load.sql`
|
||||
|
||||
- [x] **Несогласованность нейминга STG vs ODS+** ✅ ВЫПОЛНЕНО
|
||||
- STG: `batch_id`, `load_dttm`, `src_created_at_ts` → переименованы в канон `_load_id`, `_load_ts`, `event_ts`
|
||||
- Единый словарь во всех слоях снижает когнитивную нагрузку
|
||||
- Секция 6 «Переходный маппинг» удалена из `naming_conventions.md` как неактуальная
|
||||
- Файлы: 27 STG SQL + ODS load-скрипты + `naming_conventions.md` + тесты
|
||||
|
||||
- [x] **Дублирование CTE в ODS load-скриптах**
|
||||
- `WITH src AS (...)` копируется 2-3 раза в каждом из 9 ODS load-файлов
|
||||
- **Решение**: TEMP TABLE для самых сложных (airports, flights, routes); простые — оставить
|
||||
- Файлы: `sql/ods/airports_load.sql`, `sql/ods/flights_load.sql`, `sql/ods/routes_load.sql`
|
||||
- *Заметка*: Для всех транзакционных таблиц ODS внедрен паттерн TEMP TABLE для надежной работы HWM.
|
||||
|
||||
- [x] **DM слой спроектирован**
|
||||
- 5 витрин: `sales_report`, `route_performance`, `passenger_loyalty`, `airport_traffic`, `monthly_overview`
|
||||
- `sales_report`, `route_performance` — эталонные реализации; `passenger_loyalty`, `airport_traffic`, `monthly_overview` — задания для студентов
|
||||
- `route_performance` — full rebuild + AO Column Store
|
||||
|
||||
### P3: Отложено (покрыто другими документами)
|
||||
|
||||
- [~] **Крутая лестница сложности ODS→DDS**
|
||||
- Покрыто: `docs/assignment/analyst_spec.md` содержит рекомендуемый порядок выполнения
|
||||
(STG → ODS → DDS SCD1 → DDS SCD2 → DM от простого к сложному)
|
||||
|
||||
- [~] **DQ без переиспользуемых функций**
|
||||
- Отложено: переусложнение для учебного стенда (AGENTS.md: KISS)
|
||||
|
||||
- [~] **Нет документа по стратегии distribution**
|
||||
- Покрыто: подсказки по DK есть в `analyst_spec.md` и комментариях к каждому DDL
|
||||
|
||||
- [~] **Отсутствующие паттерны** (комментарии/заметки)
|
||||
- Покрыто частично: partitioning и exchange partition описаны в ADR-2 (этот документ).
|
||||
SCD3/6 и data lineage — вне скоупа курсовой
|
||||
|
||||
---
|
||||
|
||||
## ПРИНЯТЫЕ АРХИТЕКТУРНЫЕ РЕШЕНИЯ
|
||||
|
||||
### ADR-1: Города, страны, модели самолётов — атрибуты измерений, не отдельные справочники
|
||||
|
||||
**Рассматривалось**: выделить `dim_city`, `dim_country`, `dim_airplane_model` как отдельные
|
||||
измерения со своими суррогатными ключами.
|
||||
|
||||
**Решение**: оставить `city`, `country` как атрибуты `dim_airports`, а `model` — как атрибут
|
||||
`dim_airplanes`. Не создавать отдельные справочники.
|
||||
|
||||
**Обоснование**:
|
||||
1. **Star vs Snowflake.** Kimball-методология рекомендует «wide and flat» измерения.
|
||||
Вынос атрибутов в подтаблицы превращает star schema в snowflake — добавляет 2-3 JOIN-а
|
||||
в каждый запрос к факту без аналитического выигрыша. Для учебного стенда star schema —
|
||||
правильный эталон.
|
||||
2. **Нет самостоятельной сущности в домене.** Город — JSON-атрибут аэропорта в источнике
|
||||
(`airport_name::json->>'ru'`). У него нет своего бизнес-ключа, жизненного цикла,
|
||||
независимых атрибутов. Модель самолёта — аналогично.
|
||||
3. **Когнитивная нагрузка.** Лестница ODS→DDS уже крутая (6 измерений + 1 факт + SCD2).
|
||||
Добавление 2-3 измерений усложнит стенд без пропорционального обучающего эффекта.
|
||||
|
||||
**Когда отдельное измерение оправдано** (для справки студентам):
|
||||
- Город имеет собственные атрибуты из другого источника (население, регион, координаты)
|
||||
→ `dim_geography` как outrigger-измерение
|
||||
- Модель самолёта имеет независимые характеристики (производитель, сертификация, конфигурации)
|
||||
→ `dim_aircraft_type`
|
||||
- В Data Vault — `hub_city` / `hub_country` как самостоятельные бизнес-объекты (другая парадигма)
|
||||
|
||||
### ADR-2: Heap + UPSERT вместо AO + партиционирование + exchange partition
|
||||
|
||||
**Контекст**: Greenplum широко распространён в РФ — на него активно мигрировали при
|
||||
импортозамещении с Teradata и Exadata. Именно поэтому GP выбран для курсовой: опыт работы
|
||||
с ним будет напрямую релевантен первой работе студента. Тем важнее, чтобы студенты понимали,
|
||||
как устроены реальные GP-хранилища, даже если стенд использует упрощённый подход.
|
||||
|
||||
**Рассматривалось**: использовать production-паттерн крупных GP-хранилищ:
|
||||
- AO Column Store (сжатие zlib/zstd, векторное чтение, колоночное хранение)
|
||||
- Range-партиционирование по дате (`PARTITION BY RANGE (flight_date)`)
|
||||
- Обновление через замену партиций (`ALTER TABLE EXCHANGE PARTITION`) или
|
||||
`DELETE + INSERT` в рамках одной партиции вместо row-level UPDATE
|
||||
|
||||
**Решение**: партиционирование не применяем (учебные объёмы). Для storage —
|
||||
дифференцированный подход: heap для таблиц с UPDATE, AO для иммутабельных
|
||||
(см. ADR-3 с полной раскладкой).
|
||||
|
||||
**Обоснование**:
|
||||
1. **Универсальность паттерна.** UPSERT через UPDATE + INSERT работает в PostgreSQL,
|
||||
Snowflake, BigQuery, Redshift — везде. Exchange partition — GP-специфика
|
||||
(`ALTER TABLE ... EXCHANGE PARTITION FOR (...) WITH TABLE tmp_...`).
|
||||
Студент, освоив UPSERT, сможет применить его на любой платформе.
|
||||
2. **Объём данных.** На учебных ~100K строк партиционирование не даёт partition pruning
|
||||
эффекта, зато утраивает DDL (стратегия, sub-partitions, retention policy).
|
||||
Выигрыш нулевой, когнитивная нагрузка — существенная.
|
||||
3. **Простота ментальной модели.** «Вот строка, она обновилась» понятнее, чем «вот партиция,
|
||||
она заменилась целиком». Второй паттерн требует понимания storage engine, что выходит
|
||||
за рамки первого курса DWH.
|
||||
|
||||
**Что студенту важно знать про реальный GP** (для менти):
|
||||
|
||||
На продакшн-хранилищах с десятками и сотнями миллионов строк подход меняется принципиально:
|
||||
|
||||
| Аспект | Стенд (учебный) | Продакшн (реальный GP) |
|
||||
|--------|-----------------|----------------------|
|
||||
| Хранение фактов | Heap (row-oriented) | AO Column Store (сжатие, колонки) |
|
||||
| Партиционирование | Нет | Range по дате (день/месяц) |
|
||||
| Обновление | Row-level UPDATE | Exchange partition или DELETE+INSERT в партиции |
|
||||
| Причина | UPDATE на AO «раздувает» таблицу (помечает строки deleted, дописывает новые) | |
|
||||
| Когда переходить | > 10M строк, или когда VACUUM не справляется | |
|
||||
|
||||
Типичный production-паттерн загрузки факта по дням:
|
||||
```sql
|
||||
-- 1. Собрать новую партицию во временную таблицу
|
||||
CREATE TABLE tmp_fact_20170102 (LIKE dds.fact_flight_sales)
|
||||
WITH (appendonly=true, orientation=column, compresstype=zstd);
|
||||
INSERT INTO tmp_fact_20170102 SELECT ... FROM ods... WHERE flight_date = '2017-01-02';
|
||||
|
||||
-- 2. Атомарно заменить партицию (без DELETE, без UPDATE)
|
||||
ALTER TABLE dds.fact_flight_sales
|
||||
EXCHANGE PARTITION FOR ('2017-01-02') WITH TABLE tmp_fact_20170102;
|
||||
|
||||
-- 3. Удалить временную таблицу (теперь в ней старые данные)
|
||||
DROP TABLE tmp_fact_20170102;
|
||||
```
|
||||
|
||||
Преимущества exchange partition:
|
||||
- Нет row-level UPDATE → нет bloat, не нужен VACUUM
|
||||
- AO Column Store даёт 5-10x сжатие и быстрые аналитические скана
|
||||
- Partition pruning: запрос `WHERE flight_date = '2017-01-02'` читает только одну партицию
|
||||
- Атомарность: EXCHANGE — одна DDL-команда, нет окна неконсистентности
|
||||
|
||||
### ADR-3: Явный storage type для каждой таблицы + AO где нет UPDATE
|
||||
|
||||
**Проблема**: 18 из 28 таблиц в ODS/DDS/DM создаются без `WITH`-клаузы. GP по умолчанию
|
||||
создаёт heap, но студент не видит осознанного выбора — таблица «просто создаётся».
|
||||
В учебном стенде каждое решение должно быть видимым и объяснённым.
|
||||
|
||||
**Решение**: добавить явный `WITH (...)` ко всем таблицам. Где row-level UPDATE не нужен —
|
||||
перевести на AO (Row или Column) с компрессией.
|
||||
|
||||
**Целевая раскладка storage по таблицам:**
|
||||
|
||||
| Storage | Таблицы | Почему |
|
||||
|---------|---------|--------|
|
||||
| **AO Column** zstd | `dds.dim_calendar` | Write-once (generate_series), никогда не обновляется. Колоночное хранение идеально для аналитических скан. |
|
||||
| **AO Column** zstd | `dm.route_performance` | Full rebuild (TRUNCATE+INSERT), чисто аналитические чтения. |
|
||||
| **AO Row** zstd | STG: все 9 таблиц | Уже реализовано. Append-only, иммутабельные батчи. Примечание: используем **zstd (level 1)** вместо zlib, так как он обеспечивает более высокую скорость декомпрессии и лучшее сжатие в современных GP-кластерах (6.0+). |
|
||||
| **AO Row** zstd | ODS snapshot: `airports`, `airplanes`, `routes`, `seats` | Полный snapshot каждый раз. Перевести загрузку с UPSERT на TRUNCATE+INSERT — честнее для семантики «текущий срез». |
|
||||
| **AO Row** zstd | `dds.dim_tariffs` | Только INSERT новых тарифов, UPDATE не используется. |
|
||||
| **Heap** (явный) | ODS транзакционные: `bookings`, `tickets`, `flights`, `segments`, `boarding_passes` | Row-level UPDATE при SCD1 UPSERT. Heap обязателен. |
|
||||
| **Heap** (явный) | DDS измерения с UPDATE: `dim_airports`, `dim_airplanes`, `dim_passengers`, `dim_routes` | SCD1/SCD2 UPSERT с row-level UPDATE. |
|
||||
| **Heap** (явный) | `dds.fact_flight_sales` | UPDATE (is_boarded, seat_no меняются). |
|
||||
| **Heap** (явный) | DM витрины с UPSERT: `sales_report` и будущие HWM-витрины | Row-level UPDATE при инкрементальном UPSERT. |
|
||||
|
||||
**Учебная ценность**: студенты видят на практике три storage-стратегии в одном проекте:
|
||||
1. AO Column — для иммутабельных аналитических таблиц (dim_calendar, route_performance)
|
||||
2. AO Row — для append-only данных и snapshot-справочников (STG, ODS refs, dim_tariffs)
|
||||
3. Heap — для таблиц с row-level UPDATE (ODS транзакции, DDS dims с UPSERT, факт, DM)
|
||||
|
||||
И понимают **почему** выбор именно такой: UPDATE на AO = bloat + необходимость VACUUM.
|
||||
|
||||
---
|
||||
|
||||
## ЧТО ОСТАВИТЬ КАК ЕСТЬ
|
||||
|
||||
| Аспект | Почему не трогаем |
|
||||
|--------|-------------------|
|
||||
| Факт без SK | Правильное моделирование, нужен только комментарий (P1) |
|
||||
| ODS DAG с 20 задачами | Не перегружает — параллельная структура наглядна на графе |
|
||||
| 5 витрин DM | Правильное количество, каждая учит своему паттерну |
|
||||
| PL/pgSQL DQ | Достаточно для учебного проекта, фреймворк — перебор |
|
||||
| MAX+ROW_NUMBER для SK | Корректно для GP, нужен только комментарий (P1) |
|
||||
|
||||
---
|
||||
|
||||
## Сводка по трудозатратам
|
||||
|
||||
Все задачи P0–P2 выполнены. P3 отложены (покрыты другими документами).
|
||||
|
||||
| Приоритет | Действие | Статус |
|
||||
|-----------|----------|--------|
|
||||
| ~~P0~~ | ODS batch resolver: разделить логику для справочников и транзакций | ✅ |
|
||||
| ~~P0~~ | Исправить distribution key в airport_traffic | ✅ |
|
||||
| ~~P1~~ | Добавить 7 точечных комментариев | ✅ |
|
||||
| ~~P2~~ | Явный storage type + AO где нет UPDATE (ADR-3) | ✅ |
|
||||
| ~~P2~~ | Рефакторинг hashdiff → TEMP TABLE | ✅ |
|
||||
| ~~P2~~ | Переименовать STG поля в канон | ✅ |
|
||||
| ~~P2~~ | TEMP TABLE для сложных ODS load-ов | ✅ |
|
||||
| ~~P2~~ | Реализовать DM слой (5 витрин) | ✅ |
|
||||
| P3 | Маршрут изучения DDS | Покрыто: `analyst_spec.md` |
|
||||
| P3 | DQ-функции, distribution strategy, паттерны | Отложено |
|
||||
@@ -0,0 +1,52 @@
|
||||
# Учебные задания
|
||||
|
||||
> Это ветка `solution` — здесь всё уже реализовано.
|
||||
> Используйте её как справочник, а задания выполняйте на ветке `main`.
|
||||
|
||||
## Навигатор по реализации
|
||||
|
||||
На этой ветке полностью работает пайплайн `STG → ODS → DDS → DM`.
|
||||
Ниже — рекомендуемый порядок изучения: от простых паттернов к сложным.
|
||||
|
||||
### ODS: загрузка из STG
|
||||
|
||||
| Паттерн | Файл | Что посмотреть |
|
||||
|---------|------|----------------|
|
||||
| TRUNCATE + INSERT (snapshot-справочник) | `sql/ods/airports_load.sql` | Фильтрация по `stg_batch_id`, дедупликация через `ROW_NUMBER()` |
|
||||
| SCD1 UPSERT (транзакционные данные) | `sql/ods/bookings_load.sql` | TEMP TABLE → UPDATE (`IS DISTINCT FROM`) → INSERT, HWM |
|
||||
|
||||
### DDS: измерения и факт
|
||||
|
||||
| Паттерн | Файл | Что посмотреть |
|
||||
|---------|------|----------------|
|
||||
| SCD1 (простое измерение) | `sql/dds/dim_airports_load.sql` | UPSERT с суррогатным ключом |
|
||||
| SCD1 с обогащением | `sql/dds/dim_airplanes_load.sql` | JOIN с `ods.seats` для `total_seats` |
|
||||
| SCD2 (версионирование) | `sql/dds/dim_routes_load.sql` | `hashdiff`, `valid_from`/`valid_to`, денормализация |
|
||||
| Факт с point-in-time JOIN | `sql/dds/fact_flight_sales_load.sql` | SCD2 lookup, защитные LEFT JOIN |
|
||||
|
||||
### DM: витрины
|
||||
|
||||
| Паттерн | Файл | Что посмотреть |
|
||||
|---------|------|----------------|
|
||||
| UPSERT с HWM по датам | `sql/dm/sales_report_load.sql` | Инкрементальная агрегация фактов |
|
||||
| Full Rebuild (AO Column) | `sql/dm/route_performance_load.sql` | TRUNCATE + INSERT, агрегация по SCD2 `route_bk` |
|
||||
| UNION ALL (dual-role dimension) | `sql/dm/airport_traffic_load.sql` | Разворот факта: departure + arrival |
|
||||
| Двухуровневая агрегация | `sql/dm/monthly_overview_load.sql` | Честный `avg_load_factor` через 2 уровня |
|
||||
| Метод затронутых ключей | `sql/dm/passenger_loyalty_load.sql` | HWM по пассажирам, пересчёт полной истории |
|
||||
|
||||
## Техническое задание
|
||||
|
||||
Основной документ: **[analyst_spec.md](analyst_spec.md)** — ТЗ от аналитика
|
||||
с описанием всех таблиц, маппингами, бизнес-правилами и подсказками.
|
||||
|
||||
## Описания DAG-ов
|
||||
|
||||
- [STG](../bookings_to_gp_stage.md) ·
|
||||
[ODS](../bookings_to_gp_ods.md) ·
|
||||
[DDS](../bookings_to_gp_dds.md) ·
|
||||
[DM](../bookings_to_gp_dm.md)
|
||||
- [Порядок запуска DAG-ов](../reference/dag_execution_order.md)
|
||||
|
||||
## Для менторов
|
||||
|
||||
Дизайн заданий и педагогическая логика: [docs/design/assignment_design.md](../design/assignment_design.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
|
||||
```
|
||||
@@ -0,0 +1,184 @@
|
||||
# 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.
|
||||
|
||||
**Два пути lookup для аэропортов и маршрутов.**
|
||||
Аэропорты (`departure_airport_sk`, `arrival_airport_sk`) разрешаются через `ods.routes` →
|
||||
`dim_airports`. Аэропорты вылета/прилёта одинаковы во всех версиях маршрута, поэтому
|
||||
point-in-time логика не нужна — безопасно брать актуальную версию из ODS.
|
||||
|
||||
`route_sk` и `airplane_sk` разрешаются через point-in-time join с SCD2 `dim_routes`:
|
||||
|
||||
```sql
|
||||
LEFT JOIN dds.dim_routes AS rte
|
||||
ON rte.route_bk = flt.route_no
|
||||
AND flt.scheduled_departure::DATE >= rte.valid_from
|
||||
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
|
||||
```
|
||||
|
||||
Это гарантирует, что факт привязывается к той версии маршрута, которая была актуальна
|
||||
на дату рейса.
|
||||
|
||||
**UPDATE мутабельных полей.**
|
||||
UPDATE обновляет только `seat_no`, `price`, `is_boarded` (данные, которые реально
|
||||
могут измениться — посадка пассажира, корректировка цены). SK измерений не перезаписываются —
|
||||
они зафиксированы на момент вставки.
|
||||
|
||||
### 8) `finish_dds_summary`
|
||||
|
||||
Ждёт завершения DQ факта и логирует сводку.
|
||||
|
||||
## Как проверить результат
|
||||
|
||||
```bash
|
||||
make gp-psql
|
||||
```
|
||||
|
||||
```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 завершился успешно без пропуска задач.
|
||||
@@ -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 задачу с ошибкой и исправьте.
|
||||
@@ -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 задач.
|
||||
@@ -0,0 +1,156 @@
|
||||
# DAG `bookings_to_gp_stage`: `bookings-db` → `stg` в Greenplum
|
||||
|
||||
Этот DAG — основной учебный пример в стенде. Он показывает путь данных из источника **Postgres**
|
||||
(`bookings-db`, демо‑БД `demo`) в сырой слой **STG** в **Greenplum** с инкрементальной загрузкой
|
||||
и простыми проверками качества данных.
|
||||
|
||||
## Что делает DAG
|
||||
|
||||
- В источнике (`bookings-db`) генерирует следующий учебный день данных в `bookings.bookings`
|
||||
(генератор всегда “шагает” вперёд от `max(book_date)`).
|
||||
- В Greenplum загружает инкремент в `stg.bookings` через внешнюю таблицу `stg.bookings_ext`, используя PXF.
|
||||
- Сверяет количество строк между источником (за окно инкремента) и загруженным батчем.
|
||||
- Загружает инкремент в `stg.tickets` через внешнюю таблицу `stg.tickets_ext`, используя PXF.
|
||||
- Запускает DQ‑проверки для `stg.tickets` (количество, ссылочная целостность, обязательные поля).
|
||||
- Загружает справочники (full load): `stg.airports`, `stg.airplanes`, `stg.routes`, `stg.seats` + DQ.
|
||||
- Загружает транзакции: `stg.flights` (инкремент), `stg.segments` (инкремент), `stg.boarding_passes` (инкремент через tickets/bookings) + DQ.
|
||||
|
||||
## Что должно быть готово перед запуском
|
||||
|
||||
1) Стек поднят:
|
||||
|
||||
```bash
|
||||
make up
|
||||
```
|
||||
|
||||
2) Демо‑БД bookings установлена и содержит данные:
|
||||
|
||||
```bash
|
||||
make bookings-init
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
4) Airflow Connections:
|
||||
|
||||
- `bookings_db` — подключение к источнику `bookings-db`;
|
||||
- `greenplum_conn` — подключение к Greenplum.
|
||||
|
||||
По умолчанию они задаются через переменные окружения `AIRFLOW_CONN_...` в `docker-compose.yml`,
|
||||
поэтому могут не отображаться в UI — для `PostgresOperator` это нормально.
|
||||
|
||||
## Как запустить
|
||||
|
||||
1) Откройте Airflow UI: http://localhost:8080 (admin/admin).
|
||||
2) Запустите DAG `bookings_to_gp_stage` вручную (Trigger DAG).
|
||||
3) Дождитесь статуса Success у всех задач.
|
||||
|
||||
## Как это работает внутри (по шагам)
|
||||
|
||||
1) `generate_bookings_day`
|
||||
|
||||
- выполняет `sql/src/bookings_generate_day_if_missing.sql` в `bookings-db`;
|
||||
- если `bookings.bookings` пустая — вызывает `generate(...)` на `BOOKINGS_INIT_DAYS`;
|
||||
- иначе — вызывает `continue(...)`, добавляя ровно один следующий день.
|
||||
|
||||
2) `load_bookings_to_stg`
|
||||
|
||||
- выполняет `sql/stg/bookings_load.sql` в Greenplum;
|
||||
- берёт строки из `stg.bookings_ext`, которые попадают в новое окно инкремента;
|
||||
- вставляет их в `stg.bookings`, добавляя тех.колонки:
|
||||
- `event_ts` (опорная метка времени для инкремента),
|
||||
- `_load_ts`,
|
||||
- `_load_id={{ run_id }}`.
|
||||
|
||||
3) `check_row_counts`
|
||||
|
||||
- выполняет `sql/stg/bookings_dq.sql` в Greenplum;
|
||||
- считает количество строк в источнике за то же окно инкремента и сравнивает с количеством строк,
|
||||
вставленных в `stg.bookings` для текущего `_load_id`;
|
||||
- при расхождении делает `RAISE EXCEPTION` с понятным текстом.
|
||||
|
||||
4) `load_tickets_to_stg`
|
||||
|
||||
- выполняет `sql/stg/tickets_load.sql` в Greenplum;
|
||||
- так как в `bookings.tickets` нет явной временной колонки, окно инкремента берётся по `book_date`
|
||||
из связанной внешней таблицы `stg.bookings_ext` (JOIN по `book_ref`);
|
||||
- вставляет строки в `stg.tickets`, добавляя `event_ts`, `_load_ts` и `_load_id={{ run_id }}`.
|
||||
|
||||
5) `check_tickets_dq`
|
||||
|
||||
- выполняет `sql/stg/tickets_dq.sql` в Greenplum;
|
||||
- проверяет количество строк в том же окне инкремента, а также ссылочную целостность и обязательные поля;
|
||||
- при проблемах делает `RAISE EXCEPTION`, чтобы DAG падал “красным”.
|
||||
|
||||
6) Справочники (full load, параллельно где возможно)
|
||||
|
||||
Справочники загружаются “снэпшотом” (все строки) и затем проверяются DQ-скриптом.
|
||||
Порядок определяется зависимостями данных — **airports** и **airplanes** грузятся **параллельно**,
|
||||
потому что не зависят друг от друга:
|
||||
|
||||
```
|
||||
check_tickets_dq
|
||||
├─ load_airports → check_airports_dq ─┐
|
||||
│ ├─ load_routes → check_routes_dq
|
||||
└─ load_airplanes → check_airplanes_dq ─┤
|
||||
└─ load_seats → check_seats_dq
|
||||
```
|
||||
|
||||
- `load_airports_to_stg` → `check_airports_dq` (`sql/stg/airports_load.sql`, `sql/stg/airports_dq.sql`)
|
||||
- `load_airplanes_to_stg` → `check_airplanes_dq` (`sql/stg/airplanes_load.sql`, `sql/stg/airplanes_dq.sql`)
|
||||
- `load_routes_to_stg` → `check_routes_dq` (`sql/stg/routes_load.sql`, `sql/stg/routes_dq.sql`) — зависит от **airports** и **airplanes** (DQ проверяет ссылочную целостность)
|
||||
- `load_seats_to_stg` → `check_seats_dq` (`sql/stg/seats_load.sql`, `sql/stg/seats_dq.sql`) — зависит от **airplanes** (DQ проверяет `airplane_code → airplanes`)
|
||||
|
||||
7) Транзакции
|
||||
|
||||
- `load_flights_to_stg` → `check_flights_dq` (инкремент по `scheduled_departure`) — зависит от **routes**
|
||||
- `load_segments_to_stg` → `check_segments_dq` (инкремент по `book_date` через tickets/bookings) — зависит от **flights**
|
||||
- `load_boarding_passes_to_stg` → `check_boarding_passes_dq` (инкремент по `book_date` через tickets/bookings) — зависит от **segments**
|
||||
|
||||
Ветка `seats` работает параллельно с веткой `routes → flights → segments → boarding_passes`.
|
||||
Обе ветки сходятся на `finish_summary`.
|
||||
|
||||
Важно: для инкрементальных таблиц “пустое окно инкремента” допустимо — загрузка и DQ логируют `NOTICE` и завершаются успешно.
|
||||
Для snapshot-справочников (airports/airplanes/routes/seats) пустой источник считается ошибкой (DQ делает `RAISE EXCEPTION`).
|
||||
|
||||
8) `finish_summary`
|
||||
|
||||
- ждёт завершения **обеих** параллельных веток (`check_boarding_passes_dq` и `check_seats_dq`);
|
||||
- логирует краткую сводку в конце запуска.
|
||||
|
||||
## Как проверить результат
|
||||
|
||||
```bash
|
||||
make gp-psql
|
||||
```
|
||||
|
||||
Примеры запросов:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM stg.bookings;
|
||||
|
||||
SELECT
|
||||
event_ts,
|
||||
_load_ts,
|
||||
_load_id
|
||||
FROM stg.bookings
|
||||
ORDER BY event_ts DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
## Типичные ошибки
|
||||
|
||||
- `database "demo" does not exist`: демо‑БД не установлена → выполните `make bookings-init`.
|
||||
- Ошибки про `stg.*`/`stg.*_ext`: не применён DDL → запустите `bookings_stg_ddl` или `make ddl-gp`.
|
||||
- Ошибки PXF (`protocol "pxf" does not exist`, connection refused): перезапустите `greenplum` и повторите DDL.
|
||||
Для технических деталей см. `docs/reference/pxf_bookings.md`.
|
||||
|
||||
## Рекомендации по качеству решения
|
||||
|
||||
Ревью решения и список улучшений, которые делают пайплайн более “эталонным” для обучения:
|
||||
`docs/archive/bookings_stg_code_review.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). В учебных целях мы оставили их ручными, чтобы вы могли проинспектировать каждый слой после его загрузки.
|
||||
@@ -0,0 +1,244 @@
|
||||
# PRD: Greenplum Bookings DWH
|
||||
|
||||
> Курсовая работа для курса [DE Roadmap](https://github.com/dementev-dev/de-roadmap).
|
||||
> Статус: **ЧЕРНОВИК v0.1** | Дата: 2026-03-08
|
||||
|
||||
---
|
||||
|
||||
## 1. Видение продукта
|
||||
|
||||
**Greenplum Bookings DWH** — учебный стенд, на котором студент самостоятельно строит
|
||||
end-to-end ETL-пайплайн: от базы-источника до аналитических витрин.
|
||||
|
||||
Стенд имитирует реальную рабочую задачу Data-инженера:
|
||||
- Есть «боевая» система-источник (bookings-db), в которой каждый день появляются
|
||||
новые данные — как в жизни, без ограниченного объёма.
|
||||
- Есть DWH на Greenplum с классическими слоями (STG → ODS → DDS → DM).
|
||||
- Есть Airflow, оркестрирующий загрузку.
|
||||
- Есть ТЗ от «аналитика» с описанием ожидаемых таблиц и маппингов.
|
||||
|
||||
Студент получает **частично реализованный пайплайн** (эталонный вертикальный срез)
|
||||
и **дореализует остальное** по ТЗ — SQL-скрипты и таски в DAG.
|
||||
|
||||
### Почему именно bookings?
|
||||
|
||||
Домен бронирования авиабилетов выбран не ради предметной области, а благодаря
|
||||
генератору данных: каждый вызов `make bookings-generate-day` создаёт новый день
|
||||
с реалистичным объёмом. Это даёт бесконечный поток инкрементальных данных —
|
||||
как в настоящей production-системе.
|
||||
|
||||
---
|
||||
|
||||
## 2. Целевая аудитория и пререквизиты
|
||||
|
||||
**Кто:** студенты курса DE Roadmap, дошедшие до раздела «Курсовая работа».
|
||||
|
||||
**Что уже умеют** (к моменту старта):
|
||||
- Git: ветки, PR, merge, GitFlow
|
||||
- SQL: JOIN, CTE, оконные функции, планы запросов, моделирование (3NF, звезда, SCD)
|
||||
- Python: скрипты, pandas, базовое ООП
|
||||
- Docker: запуск контейнеров, логи, docker-compose
|
||||
- Airflow: понятие DAG, операторы, зависимости, UI, логи
|
||||
- Greenplum: распределение по сегментам, skew, EXPLAIN, отличие от Postgres
|
||||
|
||||
**Уровень:** уверенный джун, готовящийся к первым собеседованиям.
|
||||
|
||||
---
|
||||
|
||||
## 3. Учебные результаты (Learning Outcomes)
|
||||
|
||||
После выполнения курсовой студент умеет:
|
||||
|
||||
1. **Проектировать и реализовывать ETL-пайплайн** по слоям DWH
|
||||
(STG → ODS → DDS → DM) на реальном стеке Airflow + Greenplum.
|
||||
2. **Читать ТЗ от аналитика** (маппинги, описания таблиц) и превращать его
|
||||
в работающий SQL + DAG.
|
||||
3. **Писать идемпотентные загрузки** с инкрементальностью (HWM, _load_id,
|
||||
delete+insert), понимая, почему в Greenplum не используется MERGE.
|
||||
4. **Реализовывать SCD1/SCD2** и объяснять, когда что применяется.
|
||||
5. **Настраивать и проверять Data Quality** — понимает, зачем DQ-проверки
|
||||
и как их встроить в пайплайн.
|
||||
6. **Работать с Greenplum** как с MPP: выбирать distribution key,
|
||||
понимать heap vs AO, читать планы запросов.
|
||||
7. **Оформить проект как портфолио** — репозиторий пригоден для упаковки
|
||||
в резюме как реальный опыт работы с Airflow и Greenplum.
|
||||
|
||||
---
|
||||
|
||||
## 4. Скоуп
|
||||
|
||||
### В скоупе (In Scope)
|
||||
|
||||
| Компонент | Описание |
|
||||
|-----------------------|-------------------------------------------------------------|
|
||||
| Источник данных | bookings-db (Postgres) с генератором дней |
|
||||
| DWH | Greenplum, 4 слоя: STG, ODS, DDS, DM |
|
||||
| Оркестрация | Apache Airflow (PostgresOperator + SQL-файлы) |
|
||||
| Федеративный доступ | PXF (чтение из Postgres в Greenplum) |
|
||||
| Инфраструктура | Docker Compose (полный стенд в одной команде) |
|
||||
| Data Quality | DQ-проверки, встроенные в DAG |
|
||||
| Документация | README, ТЗ, design docs, naming conventions |
|
||||
|
||||
### Вне скоупа (Out of Scope)
|
||||
|
||||
| Что | Почему |
|
||||
|-----------------------|-------------------------------------------------------------|
|
||||
| Kafka / стриминг | Отдельный стенд в курсе |
|
||||
| BI-инструменты | Фокус на ETL, не на визуализации |
|
||||
| CI/CD | Избыточно для курсовой |
|
||||
| Spark / Trino / dbt | Отдельные стенды в курсе |
|
||||
| Второй источник | Усложнение без пропорциональной учебной ценности |
|
||||
| CSV-пайплайн | Вынести в [airflow-manual](https://github.com/dementev-dev/airflow-manual) |
|
||||
| Облачная инфраструктура | Всё локально, через Docker |
|
||||
|
||||
---
|
||||
|
||||
## 5. Архитектура стенда
|
||||
|
||||
### Сервисы (Docker Compose)
|
||||
|
||||
```
|
||||
bookings-db (Postgres 16) ──PXF──> Greenplum 6.27
|
||||
├── stg.* (стейджинг)
|
||||
├── ods.* (операционное хранилище)
|
||||
├── dds.* (детальное хранилище)
|
||||
└── dm.* (витрины)
|
||||
|
||||
pgmeta (Postgres 16) ─────────────> Airflow (webserver + scheduler)
|
||||
```
|
||||
|
||||
### Слои DWH
|
||||
|
||||
| Слой | Назначение | Паттерн загрузки | Кол-во таблиц |
|
||||
|------|-----------------------------------|---------------------------|---------------|
|
||||
| STG | Зеркало источника | TRUNCATE + INSERT (batch) | 9 |
|
||||
| ODS | Нормализованное хранилище | SCD1 UPSERT | 9 |
|
||||
| DDS | Измерения + факты (Kimball) | SCD1/SCD2 + fact load | 7 (6D + 1F) |
|
||||
| DM | Аналитические витрины | HWM-инкремент | 5 |
|
||||
|
||||
### Сущности
|
||||
|
||||
| STG / ODS | DDS | DM |
|
||||
|----------------------|--------------------------|-----------------------|
|
||||
| bookings | dim_airports | airport_traffic |
|
||||
| tickets | dim_airplanes | monthly_overview |
|
||||
| airports | dim_passengers | passenger_loyalty |
|
||||
| airplanes | dim_routes (SCD2) | route_performance |
|
||||
| routes | dim_calendar | sales_report |
|
||||
| seats | dim_tariffs | |
|
||||
| flights | fact_flight_sales | |
|
||||
| segments | | |
|
||||
| boarding_passes | | |
|
||||
|
||||
---
|
||||
|
||||
## 6. Педагогическая модель
|
||||
|
||||
Подробности — в [assignment_design.md](assignment_design.md).
|
||||
|
||||
### Принцип: «Эталонный срез + ТЗ»
|
||||
|
||||
Студент получает репозиторий, в котором:
|
||||
|
||||
1. **Эталонный вертикальный срез** — полностью реализованная цепочка
|
||||
`sales_report` и все её источники вниз по слоям (STG → ODS → DDS → DM).
|
||||
2. **ТЗ от аналитика** — описание остальных таблиц
|
||||
(analyst_spec.md — будет создан на Этапе 3, см. TODO.md).
|
||||
3. **Частично готовый DAG** — студент добавляет свои таски по аналогии.
|
||||
4. **Валидационный DAG** — студент запускает для самоконтроля.
|
||||
|
||||
### Что делает студент
|
||||
|
||||
- Пишет DDL, SQL-загрузки, DQ-проверки для назначенных таблиц
|
||||
- Добавляет таски в существующий DAG
|
||||
- Проверяет результат через валидационный DAG и запросы в Greenplum
|
||||
|
||||
### Что студент НЕ делает
|
||||
|
||||
- Не поднимает инфраструктуру с нуля (Docker Compose дан)
|
||||
- Не пишет DAG с нуля (шаблон дан)
|
||||
- Не настраивает Airflow Connections (преднастроены)
|
||||
- Не работает с PXF-конфигурацией (настроен)
|
||||
|
||||
---
|
||||
|
||||
## 7. Ветки и workflow
|
||||
|
||||
```
|
||||
main (стартовое состояние)
|
||||
├── Эталонный срез: реализованные таблицы + DAG
|
||||
├── ТЗ от аналитика
|
||||
├── Инфраструктура (Docker, Make, PXF)
|
||||
├── Заглушки / TODO-маркеры для студенческих заданий
|
||||
└── Валидационный DAG для самоконтроля
|
||||
|
||||
solution (полное решение)
|
||||
└── Все таблицы реализованы — эталон для самопроверки
|
||||
и подсказка, если студент застрял
|
||||
```
|
||||
|
||||
### Workflow студента
|
||||
|
||||
1. Форкает репозиторий
|
||||
2. Читает README и ТЗ
|
||||
3. `make up` — поднимает стенд
|
||||
4. `make bookings-init` — инициализирует источник
|
||||
5. Запускает DDL-DAG'и (эталонные таблицы создаются)
|
||||
6. Запускает ETL-DAG'и — эталонный срез работает
|
||||
7. Реализует задания из ТЗ (SQL + таски в DAG)
|
||||
8. Проверяет себя через валидационный DAG
|
||||
9. `make bookings-generate-day` — генерирует новый день, проверяет
|
||||
инкрементальность
|
||||
10. Защищает работу перед ментором
|
||||
|
||||
---
|
||||
|
||||
## 8. Критерии приёмки курсовой
|
||||
|
||||
### Для студента (самопроверка)
|
||||
|
||||
- [ ] Стенд поднимается (`make up`) без ошибок
|
||||
- [ ] Все DAG'и проходят без failed-тасков
|
||||
- [ ] Данные доезжают от STG до DM
|
||||
- [ ] Валидационный DAG проходит на всех реализованных таблицах
|
||||
- [ ] После `make bookings-generate-day` + повторного запуска DAG
|
||||
данные корректно доливаются (инкрементальность работает)
|
||||
|
||||
### Для ментора (ревью + защита)
|
||||
|
||||
- [ ] Код соответствует naming conventions (`docs/design/naming_conventions.md`)
|
||||
- [ ] SQL идемпотентен (повторный запуск не ломает данные)
|
||||
- [ ] Distribution keys выбраны осмысленно
|
||||
- [ ] Студент может объяснить: почему delete+insert, а не MERGE;
|
||||
разницу SCD1/SCD2; что такое HWM; как работает _load_id
|
||||
- [ ] Код оформлен для портфолио (чистый Git-history, README)
|
||||
|
||||
---
|
||||
|
||||
## 9. Ограничения и риски
|
||||
|
||||
| Риск / ограничение | Митигация |
|
||||
|--------------------------------------------|-------------------------------------------------|
|
||||
| Стенд тяжёлый (~8-16 GB RAM) | Указать минимальные требования; не утяжелять |
|
||||
| bookings-db генерирует данные медленно | Не добавлять нагрузку; задокументировать ожидание|
|
||||
| Студент может застрять надолго | Ветка `solution` как подсказка; еженедельные встречи |
|
||||
| Greenplum 6.x — устаревающая версия | Для учебных целей достаточно; паттерны переносимы |
|
||||
| PXF нестабилен при холодном старте | Задокументировано в README; healthcheck настроен |
|
||||
|
||||
### Требования к машине студента
|
||||
|
||||
- 2-4 CPU, 8-16 GB RAM, 25-40 GB диска
|
||||
- Linux / WSL2 / macOS
|
||||
- Docker + Docker Compose
|
||||
|
||||
---
|
||||
|
||||
## 10. План работ
|
||||
|
||||
План с чекбоксами и рекомендациями по инструментам: [TODO.md](../../TODO.md).
|
||||
|
||||
---
|
||||
|
||||
## 11. Открытые вопросы
|
||||
|
||||
1. **Название** — рабочее: «Greenplum Bookings DWH». Финализировать.
|
||||
@@ -0,0 +1,164 @@
|
||||
# Дизайн курсового задания
|
||||
|
||||
> Тактические решения по нарезке задания, порядку выполнения и самопроверке.
|
||||
> Стратегию и контекст см. в [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. Развитие лабораторных
|
||||
|
||||
Перед добавлением или пересмотром заданий прочитайте
|
||||
[заметку о развитии лабораторных](labs_development.md): там зафиксированы
|
||||
аудитория, решение по PXF-практикуму, предложения по обязательному циклу
|
||||
и общие критерии подготовки заданий.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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_<mart>_delta ON COMMIT DROP AS
|
||||
SELECT
|
||||
<grain_columns>,
|
||||
<denormalized_attributes>,
|
||||
<aggregate_metrics>
|
||||
FROM dds.fact_flight_sales AS f
|
||||
JOIN ...
|
||||
WHERE <grain_date_or_key> IN (
|
||||
-- HWM-фильтр: берём только зёрна, затронутые новыми фактами
|
||||
SELECT DISTINCT <grain_column>
|
||||
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.<mart>
|
||||
)
|
||||
)
|
||||
GROUP BY <grain_columns>, <denormalized_attributes>;
|
||||
|
||||
-- Шаг 2: UPDATE существующих строк (только если что-то изменилось).
|
||||
UPDATE dm.<mart> AS tgt
|
||||
SET <metrics> = src.<metrics>,
|
||||
updated_at = now(),
|
||||
_load_id = '{{ run_id }}',
|
||||
_load_ts = now()
|
||||
FROM tmp_<mart>_delta AS src
|
||||
WHERE tgt.<grain> = src.<grain>
|
||||
AND (<columns> IS DISTINCT FROM ...);
|
||||
|
||||
-- Шаг 3: INSERT новых строк.
|
||||
INSERT INTO dm.<mart> (...)
|
||||
SELECT ... FROM tmp_<mart>_delta AS src
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM dm.<mart> AS tgt
|
||||
WHERE tgt.<grain> = src.<grain>
|
||||
);
|
||||
```
|
||||
|
||||
**Применимость по витринам:**
|
||||
- `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` для всех строк
|
||||
@@ -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 = <stg_batch_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 = '<stg_batch_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`.
|
||||
@@ -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` (по умолчанию `{{ ds_nodash }}`) — метка батча, которая попадает в `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. Требования к читаемости и комментариям
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
# Схема БД 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_Routes -->|dep/arr airports| FACT_Sales
|
||||
ODS_Boarding -->|LEFT JOIN seat_no| FACT_Sales
|
||||
|
||||
%% Dimensions to Fact
|
||||
DIM_Calendar -->|calendar_sk| FACT_Sales
|
||||
DIM_Airports -->|dep/arr _sk| FACT_Sales
|
||||
DIM_Airplanes -->|airplane_sk| FACT_Sales
|
||||
DIM_Tariffs -->|tariff_sk| FACT_Sales
|
||||
DIM_Passengers -->|passenger_sk| FACT_Sales
|
||||
DIM_Routes -->|route_sk| FACT_Sales
|
||||
|
||||
%% 5. DM (Vitrines)
|
||||
subgraph DM_Layer [Layer: DM Data Marts]
|
||||
direction TB
|
||||
DM_Sales[dm.sales_report]:::dm
|
||||
DM_Traffic[dm.airport_traffic]:::dm
|
||||
DM_Route[dm.route_performance]:::dm
|
||||
DM_Monthly[dm.monthly_overview]:::dm
|
||||
DM_Loyalty[dm.passenger_loyalty]:::dm
|
||||
end
|
||||
|
||||
%% DDS to DM
|
||||
FACT_Sales --> DM_Sales
|
||||
FACT_Sales --> DM_Traffic
|
||||
FACT_Sales --> DM_Route
|
||||
FACT_Sales --> DM_Monthly
|
||||
FACT_Sales --> DM_Loyalty
|
||||
DIM_Airports -.-> DM_Sales
|
||||
DIM_Airports -.-> DM_Traffic
|
||||
DIM_Tariffs -.-> DM_Sales
|
||||
DIM_Tariffs -.-> DM_Loyalty
|
||||
DIM_Calendar -.-> DM_Sales
|
||||
DIM_Calendar -.-> DM_Traffic
|
||||
DIM_Calendar -.-> DM_Route
|
||||
DIM_Calendar -.-> DM_Monthly
|
||||
DIM_Calendar -.-> DM_Loyalty
|
||||
DIM_Routes -.-> DM_Route
|
||||
DIM_Routes -.-> DM_Monthly
|
||||
DIM_Routes -.-> DM_Loyalty
|
||||
DIM_Airplanes -.-> DM_Monthly
|
||||
DIM_Passengers -.-> DM_Loyalty
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Сводка объектов по слоям
|
||||
|
||||
### 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 | Lookup-путь | Примечание |
|
||||
|----|-------------|------------|
|
||||
| `calendar_sk` | `dim_calendar` | Дата вылета |
|
||||
| `departure_airport_sk` | `ods.routes` → `dim_airports` | Аэропорт вылета (эталонный справочник) |
|
||||
| `arrival_airport_sk` | `ods.routes` → `dim_airports` | Аэропорт прилёта (эталонный справочник) |
|
||||
| `airplane_sk` | `dim_routes` → `dim_airplanes` | Самолёт (point-in-time SCD2) |
|
||||
| `tariff_sk` | `dim_tariffs` | Тариф |
|
||||
| `passenger_sk` | `dim_passengers` | Пассажир |
|
||||
| `route_sk` | `dim_routes` | Версия маршрута (SCD2, point-in-time) |
|
||||
|
||||
Метрики: `price` (NUMERIC), `is_boarded` (BOOLEAN).
|
||||
Degenerate keys: `book_ref`, `ticket_no`, `flight_id`, `book_date`, `seat_no`.
|
||||
|
||||
Детали: [`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) — ТЗ от аналитика (курсовое задание)
|
||||
@@ -0,0 +1,197 @@
|
||||
# Развитие лабораторных работ
|
||||
|
||||
Рабочая заметка для автора программы, 22 сентября 2026 года.
|
||||
Документ хранится в ветке `solution`; оценка программы и ссылки
|
||||
на действующие задания и код относятся к учебной ветке `main`.
|
||||
Приоритет — закрыть пробелы обязательного цикла. Принятое решение о новой
|
||||
лабораторной записано отдельно от предложений, которые еще обсуждаются.
|
||||
Действующий маршрут и требования находятся в [учебных заданиях](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/docs/assignment/README.md)
|
||||
и [ТЗ](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/docs/assignment/analyst_spec.md).
|
||||
|
||||
## Аудитория и цель
|
||||
|
||||
Студент хорошо изучил PostgreSQL и прошел 16-часовой курс Greenplum от Яндекса.
|
||||
Теперь ему нужна практика применения этих знаний в работе Data Engineer.
|
||||
Стенд служит учебным пособием, похожим на небольшое хранилище компании:
|
||||
здесь есть источники, связанные слои данных, загрузки и потребители.
|
||||
|
||||
Программа должна дать опыт подключения источника, построения загрузок
|
||||
и моделей, оркестрации, проверки результата и восстановления после ошибок.
|
||||
Знакомые SQL-инструменты студент применяет к инженерным задачам и объясняет
|
||||
свои решения на данных. Разнообразие работ определяется новыми действиями
|
||||
и решениями; дополнительная витрина полезна, если она дает такой опыт.
|
||||
|
||||
Подготовка по Airflow пока не задана. Первые необходимые действия в UI
|
||||
нужно сопровождать короткой инструкцией или ссылкой на нее. Самостоятельное
|
||||
написание задач и зависимостей вводится явно в соответствующей работе.
|
||||
|
||||
## Метод и рост самостоятельности
|
||||
|
||||
Стенд учебный: его можно ломать и восстанавливать. Готовая реализация
|
||||
позволяет сначала увидеть работающую цепочку, а затем самостоятельно
|
||||
реализовать выбранный участок. Полнота эталона при первом запуске
|
||||
не ограничивает состав лабораторных.
|
||||
|
||||
Этот прием уже используется в
|
||||
[программе Clickstream](https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/specs/2026-09-01-labs-program.md):
|
||||
при «вычитании» студент удаляет готовую реализацию и пишет свою;
|
||||
при работе «собери рядом» сравнивает свою реализацию с работающей;
|
||||
при расследовании находит и устраняет причину подготовленной ошибки.
|
||||
Можно также расширять стенд новым источником или объектом по ТЗ.
|
||||
|
||||
Границу самостоятельной работы определяет навык: в задании по SQL можно
|
||||
оставить готовый DAG, а в задании по оркестрации — готовые SQL-скрипты.
|
||||
Вычитание дает способ выделить участок для работы; самостоятельность
|
||||
обеспечивают решения, которые студент принимает и проверяет:
|
||||
|
||||
1. В первых работах даны требования к результату, близкий пример и готовая
|
||||
проверка. Студент выбирает реализацию и испытывает ее на заданных случаях.
|
||||
2. Дальше студент обосновывает способ загрузки или устройство таблицы,
|
||||
пишет собственные проверки и показывает, какие ошибки они обнаруживают.
|
||||
3. В работах на диагностику даны симптом и критерий восстановления.
|
||||
Студент сам находит причину, выбирает исправление и подтверждает результат.
|
||||
|
||||
Так помощь постепенно сокращается, а ответственность растет.
|
||||
Заранее оставлять заглушки во всех учебных участках не требуется.
|
||||
В частности, зависимость ODS от общего STG-батча объясняет
|
||||
[готовый STG в текущей программе](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/docs/assignment/analyst_spec.md#почему-stg-слой-уже-реализован),
|
||||
но позволяет временно разобрать его в отдельной работе.
|
||||
|
||||
## Принято: прием справочника входит в обязательную программу
|
||||
|
||||
Добавить лабораторную **«Восстановите прием справочника из источника»**.
|
||||
Она закрывает практику подключения через PXF и загрузки полного снимка
|
||||
в STG, которые сейчас даны готовыми.
|
||||
|
||||
Предлагаемый объем: восстановить внешнюю таблицу для существующего
|
||||
справочника Postgres, написать его загрузку в STG и проверить передачу
|
||||
полей, ключей и меток батча. Студент должен различить повтор загрузки
|
||||
в том же батче и получение нового снимка. Результат должен снова
|
||||
участвовать в действующей цепочке ODS → DDS → DM.
|
||||
|
||||
Конкретный справочник, состав удаляемых файлов, место в маршруте и проверки
|
||||
еще предстоит выбрать. Кандидат — `airports`: рядом есть
|
||||
[внешняя и внутренняя таблицы](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/sql/stg/airports_ddl.sql),
|
||||
[загрузка снимка](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/sql/stg/airports_load.sql) и
|
||||
[преобразование в ODS](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/sql/ods/airports_load.sql).
|
||||
|
||||
## Покрытие обязательного цикла
|
||||
|
||||
Оценка основана на текущем ТЗ и коде. Наличие готового примера или главы
|
||||
для чтения учитывается отдельно от самостоятельной реализации.
|
||||
|
||||
| Участок | Что студент делает сейчас | Что стоит добавить |
|
||||
|---|---|---|
|
||||
| PXF и снимок STG | Запускает готовую цепочку | Принятая лабораторная по приему справочника |
|
||||
| Инкремент STG | Использует готовый отбор по дате источника | Самостоятельную загрузку новых данных и проверку границы отбора |
|
||||
| Снимки ODS | Реализует `airplanes`, `seats` и их DQ | Сохранить задания; использовать их после знакомства с приемом данных |
|
||||
| Инкремент ODS | Использует готовые загрузки | Обработку новых ключей и изменений уже полученных строк |
|
||||
| Измерения DDS | Реализует SCD1 и SCD2 | Сохранить существующий блок |
|
||||
| Факт DDS | Читает пример и запускает готовую сборку | Самостоятельную сборку факта с ключами измерений |
|
||||
| Витрины DM | Реализует четыре витрины с разными способами пересчета | Сохранить существующий блок; сначала закрыть пробелы ниже DM |
|
||||
| DDL и физическая организация | Получает готовые таблицы, типы и ключи распределения | Написать DDL одного объекта и проверить выбор распределения опытом; предложение раскрыто ниже |
|
||||
| Airflow | Запускает готовые DAG; задачи уже подключены к SQL | Самостоятельное включение загрузок и проверок в DAG |
|
||||
| DQ и повторяемость | Пишет `*_dq.sql`; для ODS проверяет повтор; пользуется `bookings_validate` | Обязательные опыты на обнаружение ошибки, идемпотентность и восстановление после сбоя |
|
||||
|
||||
## Предложения по оставшимся пробелам
|
||||
|
||||
Следующие изменения пока не утверждены как обязательные лабораторные.
|
||||
|
||||
1. **Загрузите новые и измененные данные: STG → ODS.** Кандидат —
|
||||
`bookings`: [прием в STG](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/sql/stg/bookings_load.sql) и
|
||||
[обновление ODS](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/sql/ods/bookings_load.sql). Проверить первую
|
||||
загрузку, следующую пачку, повтор и отсутствие новых данных.
|
||||
Для изменения существующего ключа подготовить новую версию в STG:
|
||||
текущий отбор источника по `book_date` сам по себе не подхватывает
|
||||
исправления старых бронирований. Различие времени события и времени
|
||||
загрузки — часть учебного результата. Разделение на две работы зависит
|
||||
от объема самостоятельного SQL.
|
||||
2. **Соберите факт продаж.** После измерений восстановить
|
||||
[загрузку факта](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/sql/dds/fact_flight_sales_load.sql): сохранить
|
||||
зерно `(ticket_no, flight_id)`, подобрать ключи измерений и версию
|
||||
маршрута на дату рейса, обработать обновления и повторный запуск.
|
||||
Проверить покрытие ключей, значения и отсутствие размножения строк.
|
||||
Простого совпадения количества строк недостаточно.
|
||||
3. **Организуйте загрузку в Airflow и восстановите ее после сбоя.**
|
||||
Использовать уже написанный SQL, самостоятельно связать задачи загрузки
|
||||
и DQ. На подготовленном входе вызвать сбой, определить состояние данных
|
||||
и завершить обработку повторным запуском. Генератор управляется отдельно:
|
||||
его повтор продвигает источник на новый день и меняет условия опыта.
|
||||
4. **Проверьте физическую организацию таблицы в Greenplum.** Предлагается
|
||||
включить в обязательный цикл компактный опыт: написать DDL, сравнить
|
||||
два варианта распределения одних данных, посмотреть строки по сегментам
|
||||
и планы одного запроса, обосновать выбор. Результат запроса должен
|
||||
совпадать в обоих вариантах. Наличие нескольких сегментов и достаточный
|
||||
для наблюдения объем нужно проверить при подготовке работы. Место опыта
|
||||
еще не выбрано; кандидат — задание по факту. Подробную оптимизацию
|
||||
можно развить в факультативах.
|
||||
|
||||
## Предлагаемые переходы между работами
|
||||
|
||||
Порядок обсуждения новых работ: прием справочника → инкремент STG/ODS →
|
||||
сборка факта → оркестрация и восстановление. Между ними остаются
|
||||
существующие задания на снимки ODS, измерения и витрины. Точное место
|
||||
новых работ, включая опыт с распределением, еще обсуждается.
|
||||
|
||||
Для встраивания в маршрут предлагаются следующие границы:
|
||||
|
||||
| Новая работа | Состояние перед началом | Результат для продолжения |
|
||||
|---|---|---|
|
||||
| Прием справочника | Пройден быстрый старт, работает эталонная цепочка до `sales_report`; выбран участок для вычитания | Собственная загрузка получает новый снимок STG, который участвует в общем батче справочников и принимается ODS |
|
||||
| Инкремент STG/ODS | Разобраны прием снимка и существующие задания ODS; подготовлены таблицы и входные пачки | Собственные загрузки дают ожидаемое текущее состояние ODS после всех проверочных случаев |
|
||||
| Сборка факта | Реализованы и проверены измерения DDS | Факт построен собственным SQL, проверены ключи и значения; на нем можно выполнять задания DM |
|
||||
| Оркестрация и восстановление | SQL выбранного участка реализован и проверен | DAG выполняет загрузку и DQ в нужном порядке; после подготовленного сбоя обработка завершается с ожидаемыми данными |
|
||||
|
||||
Готовность оценивается по нужному этапу: на исходной `main` часть таблиц
|
||||
еще является заданием, поэтому проход всех проверок будущих слоев
|
||||
не может быть условием начала первой работы. Действующие особенности
|
||||
запусков и состояния слоев описаны в [порядке выполнения DAG](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/docs/dag_execution_order.md).
|
||||
|
||||
Предложения основаны на чтении исходников; воспроизводимость новых
|
||||
сценариев на стенде еще не проверялась.
|
||||
|
||||
## Как подготовить лабораторную
|
||||
|
||||
Эти критерии общие для обязательных работ и факультативов. Перед написанием
|
||||
инструкции определите:
|
||||
|
||||
- **Навык:** рабочая ситуация, новое действие или решение DE и его отличие
|
||||
от уже пройденных работ.
|
||||
- **Вход:** необходимые задания, состояние таблиц и загрузок, подготовка
|
||||
данных и способ воспроизвести ситуацию.
|
||||
- **Самостоятельная часть:** что студент получает готовым, что пишет
|
||||
или меняет сам и какие решения должен обосновать.
|
||||
- **Результат:** что будет работать после задания и по каким наблюдениям
|
||||
студент сможет это подтвердить.
|
||||
- **Проверочные случаи:** конкретные данные или события, ожидаемый результат
|
||||
каждого случая и способ обнаружить неверное решение.
|
||||
- **Выход:** что сохраняется для следующей работы и как получить нужное
|
||||
состояние стенда после опыта или перерыва.
|
||||
|
||||
Для инкремента получение новых строк из источника и применение уже
|
||||
полученных изменений в ODS проверяются отдельно. Для собственной DQ-проверки
|
||||
нужны отказ на известной ошибке и успех после исправления. Сверка с готовой
|
||||
реализацией подтверждает совпадение поведения; проверки по источнику
|
||||
подтверждают полноту и бизнес-правила.
|
||||
|
||||
Полная пересборка стенда допустима. Адресное восстановление требуется там,
|
||||
где оно само является предметом работы. При пересборке инструкция должна
|
||||
объяснять, как снова применить сохраненные файлы решения и получить
|
||||
состояние для продолжения.
|
||||
|
||||
Лабораторная готова к выдаче, когда все ее проверочные случаи пройдены
|
||||
на стенде из описанного начального состояния, проверки обнаружили
|
||||
предусмотренные ошибки, а заявленный выход позволяет продолжить маршрут.
|
||||
Команды и результаты проверки сохраняются вместе с изменением задания.
|
||||
Текстовая вычитка и проверка ссылок подтверждают только качество документа.
|
||||
|
||||
## Основа для будущих факультативов
|
||||
|
||||
Факультативы расширяют опыт за пределами обязательного набора. При генерации
|
||||
кандидатов используйте общие критерии выше и указывайте, после каких
|
||||
обязательных работ доступен каждый сюжет.
|
||||
|
||||
Направления для последующей генерации: изменения схемы источника,
|
||||
опоздавшие данные, история и пересчеты, углубленная оптимизация запросов
|
||||
Greenplum, свежесть данных, разграничение доступа, новые способы приема.
|
||||
Это исходные темы для отбора, а не утвержденный список заданий.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Протокол сквозного (E2E) тестирования ETL
|
||||
|
||||
Этот документ описывает процедуру полной проверки цепочки ETL: `STG -> ODS -> DDS -> DM`.
|
||||
Цель теста — убедиться в корректности инкрементальной загрузки, работы паттерна `Temporary Table` и механизмов `HWM`.
|
||||
|
||||
**Управление DAG'ами — только через Airflow UI или REST API.**
|
||||
Не используйте CLI-команды внутри контейнера (`docker exec ... airflow dags ...`) — они могут молча не сработать (например, `unpause` не снимает паузу). REST API работает надёжно и приближает опыт к реальной боевой эксплуатации.
|
||||
|
||||
---
|
||||
|
||||
## 1. Подготовка окружения
|
||||
|
||||
Убедитесь, что все сервисы запущены и генератор инициализирован.
|
||||
|
||||
```bash
|
||||
make up
|
||||
make bookings-init
|
||||
```
|
||||
|
||||
**Доступ к API:**
|
||||
В `docker-compose.yml` включена базовая аутентификация (`basic_auth`).
|
||||
Для curl-запросов используйте учетные данные из вашего `.env` файла (переменные `AIRFLOW_USER` и `AIRFLOW_PASSWORD`, по умолчанию `admin:admin`).
|
||||
|
||||
- **UI:** `http://localhost:8080`
|
||||
- **API Endpoint:** `http://localhost:8080/api/v1`
|
||||
|
||||
---
|
||||
|
||||
## 2. Очистка данных (Reset)
|
||||
|
||||
Перед началом теста необходимо полностью очистить все слои DWH.
|
||||
|
||||
```bash
|
||||
make dwh-truncate
|
||||
```
|
||||
*(Если вы меняли DDL, лучше полностью пересоздать схемы: `make gp-psql -c "DROP SCHEMA IF EXISTS ods CASCADE; DROP SCHEMA IF EXISTS dds CASCADE; DROP SCHEMA IF EXISTS dm CASCADE; CREATE SCHEMA ods; CREATE SCHEMA dds; CREATE SCHEMA dm;"`)*
|
||||
|
||||
---
|
||||
|
||||
## 3. Этап 1: Создание схем и Загрузка за первый день (Initial Load)
|
||||
|
||||
Рекомендуется запускать слои последовательно, дожидаясь завершения предыдущего.
|
||||
|
||||
### Создание DDL
|
||||
Откройте **Airflow UI** (`http://localhost:8080`) и нажмите кнопку **▶ Play -> Trigger DAG** для DDL-дагов:
|
||||
1. `bookings_stg_ddl`
|
||||
2. `bookings_ods_ddl`
|
||||
3. `bookings_dds_ddl`
|
||||
4. `bookings_dm_ddl`
|
||||
|
||||
### Запуск пайплайна (Day 1)
|
||||
После успешного создания таблиц, запустите DAG загрузки `bookings_to_gp_stage`.
|
||||
|
||||
**Через UI:** откройте http://localhost:8080, **снимите DAG с паузы** (переключатель слева от названия) и нажмите **▶ Play -> Trigger DAG**.
|
||||
|
||||
**Через REST API (рекомендуется для автоматизации и агентов):**
|
||||
|
||||
**Важно:** при старте стенда все DAG-и находятся на паузе. Каждый DAG перед первым запуском нужно «разморозить» (unpause), иначе запуск зависнет в статусе `queued`. Для каждого DAG требуется два шага: **1) unpause, 2) trigger**.
|
||||
|
||||
```bash
|
||||
# 1. Снятие с паузы (обязательно перед первым запуском!)
|
||||
curl -s -X PATCH "http://localhost:8080/api/v1/dags/bookings_to_gp_stage" \
|
||||
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"is_paused": false}'
|
||||
|
||||
# 2. Запуск
|
||||
curl -s -X POST "http://localhost:8080/api/v1/dags/bookings_to_gp_stage/dagRuns" \
|
||||
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
### Проверка статуса
|
||||
Дождитесь, пока DAG перейдет в статус `success` (следите в UI или опрашивайте через API):
|
||||
```bash
|
||||
curl -s "http://localhost:8080/api/v1/dags/bookings_to_gp_stage/dagRuns?order_by=-execution_date&limit=1" \
|
||||
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" | python3 -c "import sys,json; print(json.load(sys.stdin)['dag_runs'][0]['state'])"
|
||||
```
|
||||
|
||||
### Запуск остальных слоёв
|
||||
Поочерёдно запускайте каждый следующий слой **по той же схеме: unpause + trigger** (замените `<dag_id>`):
|
||||
1. `bookings_to_gp_ods`
|
||||
2. `bookings_to_gp_dds`
|
||||
3. `bookings_to_gp_dm`
|
||||
|
||||
```bash
|
||||
# Повторите для каждого DAG из списка выше
|
||||
DAG_ID=bookings_to_gp_ods
|
||||
|
||||
# 1. Unpause
|
||||
curl -s -X PATCH "http://localhost:8080/api/v1/dags/${DAG_ID}" \
|
||||
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"is_paused": false}'
|
||||
|
||||
# 2. Trigger
|
||||
curl -s -X POST "http://localhost:8080/api/v1/dags/${DAG_ID}/dagRuns" \
|
||||
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Этап 2: Проверка инкремента
|
||||
|
||||
Эмулируйте появление данных за второй день и проверьте дозагрузку. DAG слоя STG автоматически сгенерирует новый день в базе-источнике перед загрузкой.
|
||||
|
||||
```bash
|
||||
# Повторный запуск цепочки ETL (DAG STG сам сгенерирует новый день)
|
||||
# Снова нажмите "Trigger DAG" в UI для каждого слоя (STG -> ODS -> DDS -> DM).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Цикл отладки: Логи и Перезапуск (Clear)
|
||||
|
||||
Если DAG упал, **не нужно пересоздавать стенд с нуля**. Airflow позволяет исправить код и перезапустить только упавшие задачи.
|
||||
|
||||
Для выполнения команд ниже вам понадобится **Run ID** упавшего запуска. Его можно скопировать из UI (вкладка *Graph* -> кликнуть на фон сетки -> вкладка *Details* -> `Run ID`) или получить последним API-запросом:
|
||||
|
||||
```bash
|
||||
# Получить Run ID последнего запуска ODS
|
||||
curl -s "http://localhost:8080/api/v1/dags/bookings_to_gp_ods/dagRuns?order_by=-execution_date&limit=1" \
|
||||
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" | grep -o '"dag_run_id": "[^"]*"'
|
||||
```
|
||||
|
||||
### Чтение логов через API
|
||||
Подставьте ваш `<RUN_ID>` (например, `manual__2026-03-03T...`) и имя упавшей таски:
|
||||
```bash
|
||||
curl -s "http://localhost:8080/api/v1/dags/bookings_to_gp_ods/dagRuns/<RUN_ID>/taskInstances/<TASK_ID>/logs/1" \
|
||||
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}"
|
||||
```
|
||||
|
||||
### Перезапуск задачи (Clear)
|
||||
1. Прочитайте ошибку в логах.
|
||||
2. Исправьте SQL-файл локально на хосте.
|
||||
3. Очистите состояние упавших задач (`only_failed: true`) в конкретном запуске, передав ваш `<RUN_ID>`:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:8080/api/v1/dags/bookings_to_gp_ods/clearTaskInstances" \
|
||||
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"only_failed": true,
|
||||
"reset_dag_runs": true,
|
||||
"dag_run_id": "<RUN_ID>"
|
||||
}'
|
||||
```
|
||||
После этого планировщик подхватит обновленный SQL-код и продолжит выполнение DAG с точки падения. Вы также можете сделать это в UI: клик по упавшей задаче -> кнопка **Clear**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Финальная верификация (Критерии успеха)
|
||||
|
||||
Выполните SQL-запрос для сверки данных:
|
||||
|
||||
```bash
|
||||
make gp-psql -c "
|
||||
SELECT 'STG' as layer, COUNT(*) FROM stg.bookings
|
||||
UNION ALL
|
||||
SELECT 'ODS' as layer, COUNT(*) FROM ods.bookings
|
||||
UNION ALL
|
||||
SELECT 'DDS' as layer, COUNT(*) FROM dds.fact_flight_sales
|
||||
UNION ALL
|
||||
SELECT 'DM ' as layer, COUNT(*) FROM dm.sales_report;
|
||||
"
|
||||
```
|
||||
|
||||
**Критерии корректности:**
|
||||
1. **Инкремент STG**: Количество строк в `stg.bookings` после Этапа 2 больше, чем после Этапа 1.
|
||||
2. **Инкремент ODS**: Количество строк в `ods.bookings` выросло. В ODS нет дублей (`SELECT book_ref FROM ods.bookings GROUP BY book_ref HAVING COUNT(*) > 1` должен вернуть 0 строк).
|
||||
3. **ODS Справочники**: Количество строк в `ods.airports` и `ods.routes` не должно меняться между днями (работает паттерн TRUNCATE+INSERT полного снимка).
|
||||
4. **HWM в DM**: Витрина `sales_report` содержит данные за оба дня. Значение `COUNT(*)` после Этапа 2 выросло.
|
||||
5. **Lineage**: Поля `_load_id` и `_load_ts` во всех слоях содержат метки соответствующих запусков (`manual__...`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Зафиксированный опыт (Типичные ошибки)
|
||||
|
||||
- **Рассинхронизация DDL и Load скриптов**: Частая причина падения ODS/DDS — несовпадение имен колонок (например, `amount` vs `segment_amount`) или типов данных (например, `INTEGER[]` vs `TEXT`) между схемой таблицы и запросом загрузки. Внимательно читайте логи задачи.
|
||||
- **Работа с массивами**: При генерации `hashdiff` в Greenplum/PostgreSQL нельзя использовать пустую строку `''` в `COALESCE` для массива. Массив нужно предварительно привести к тексту: `COALESCE(days_of_week::TEXT, '')`.
|
||||
- **Кавычки в psql**: При выполнении ручных проверок через `psql -c "..."` помните, что строковые литералы должны оборачиваться в **одинарные кавычки** (`'text'`), а двойные кавычки (`"text"`) интерпретируются как идентификаторы колонок.
|
||||
@@ -1,80 +0,0 @@
|
||||
# Мини‑README по учебному DAG bookings_to_gp_stage (черновик)
|
||||
|
||||
_Внутренний файл, чтобы не забыть договорённости. Перед итоговой сдачей документацию по блоку bookings/STG нужно будет аккуратно собрать и переписать._
|
||||
|
||||
## 1. Что делает DAG
|
||||
|
||||
- DAG `bookings_to_gp_stage` показывает учебный поток:
|
||||
- источник: демо‑БД `bookings-db` (Postgres, схема `bookings`, таблица `bookings.bookings`);
|
||||
- при каждом запуске генерируется один учебный день данных (идемпотентно);
|
||||
- данные из `bookings.bookings` переливаются в сырой слой `stg.bookings` в Greenplum через PXF‑внешнюю таблицу `stg.bookings_ext`.
|
||||
- Слой `stg` задуман как «сырой»:
|
||||
- все бизнес‑колонки (`book_ref`, `book_date`, `total_amount`) хранятся как `TEXT`;
|
||||
- есть тех.колонки `src_created_at_ts`, `load_dttm`, `batch_id`.
|
||||
|
||||
Подробный дизайн описан в `docs/internal/bookings_stg_design.md`.
|
||||
|
||||
## 2. Что нужно, чтобы DAG завёлся
|
||||
|
||||
Минимальные предпосылки:
|
||||
|
||||
- Стенд поднят: `make up`.
|
||||
- Демо‑БД bookings инициализирована: `make bookings-init`.
|
||||
- В Greenplum применён DDL (созданы схема `stg` и таблицы `stg.bookings_ext` / `stg.bookings`):
|
||||
- учебный вариант: запустить DAG `bookings_stg_ddl` (он использует `sql/stg/bookings_ddl.sql`);
|
||||
- технический шорткат: `make ddl-gp` применяет все DDL разом вручную. Команда сама не вызывается при старте контейнеров, её нужно запустить явно.
|
||||
- В Airflow есть коннекты:
|
||||
- `greenplum_conn` — к Greenplum;
|
||||
- `bookings_db` — к сервису `bookings-db`.
|
||||
По умолчанию они задаются через переменные окружения `AIRFLOW_CONN_...` в docker-compose,
|
||||
поэтому могут не отображаться в UI, но `PostgresOperator` найдёт их по `conn_id`. При желании
|
||||
их можно создать/отредактировать вручную через Admin → Connections.
|
||||
|
||||
## 3. Последовательность задач в DAG
|
||||
|
||||
- `generate_bookings_day`:
|
||||
- PostgresOperator к `bookings-db`;
|
||||
- выполняет SQL `/sql/src/bookings_generate_day_if_missing.sql`;
|
||||
- скрипт смотрит на `max(book_date)` в `bookings.bookings`:
|
||||
- если база пустая — берёт стартовую дату из GUC и генерирует `bookings.init_days` суток;
|
||||
- если данные уже есть — добавляет один следующий учебный день после `max(book_date)` и пишет NOTICE с интервалом генерации.
|
||||
- `load_bookings_to_stg`:
|
||||
- PostgresOperator к Greenplum;
|
||||
- выполняет SQL `/sql/stg/bookings_load.sql`;
|
||||
- считает «старый» максимум `src_created_at_ts` (по предыдущим батчам) и грузит только новые строки из `stg.bookings_ext`, заполняя `src_created_at_ts`, `load_dttm`, `batch_id={{ ds_nodash }}`.
|
||||
- `check_row_counts`:
|
||||
- PostgresOperator к Greenplum;
|
||||
- выполняет SQL `/sql/stg/bookings_dq.sql`;
|
||||
- за то же окно инкремента считает количество строк в источнике и в `stg.bookings` (по текущему `batch_id`);
|
||||
- при расхождении делает `RAISE EXCEPTION` с понятным текстом ошибки.
|
||||
- `finish_summary`:
|
||||
- логирует итог выполнения DAG за одно срабатывание.
|
||||
|
||||
## 4. Как этим пользоваться студенту (черновой сценарий)
|
||||
|
||||
1. Поднять стенд и подготовить источники:
|
||||
- `make up` (Airflow инициализируется автоматически при первом старте)
|
||||
- `make bookings-init`
|
||||
- `make ddl-gp`
|
||||
2. Открыть Airflow UI (`http://localhost:8080`) и включить DAG `bookings_to_gp_stage`.
|
||||
3. Вызвать `Trigger` DAG (дату логического запуска можно оставить по умолчанию — она используется только как метка `batch_id`).
|
||||
4. Посмотреть:
|
||||
- в `bookings-db` ― что появился день с бронированиями;
|
||||
- в Greenplum (`make gp-psql`) — данные в `stg.bookings`:
|
||||
- `SELECT * FROM stg.bookings LIMIT 10;`
|
||||
- `SELECT src_created_at_ts, load_dttm, batch_id FROM stg.bookings ORDER BY src_created_at_ts DESC LIMIT 10;`
|
||||
5. Перезапустить DAG ещё несколько раз и увидеть, что:
|
||||
- генерация в `bookings.bookings` идёт по одному дню вперёд от текущего `max(book_date)`;
|
||||
- в `stg.bookings` появляются только новые записи (delta), помеченные разными `batch_id`.
|
||||
|
||||
## 5. Примечания «на потом»
|
||||
|
||||
- Текущая документация по блоку bookings/STG разбросана:
|
||||
- `README.md` (общий обзор стенда),
|
||||
- `docs/internal/bookings_tz.md` (источник bookings),
|
||||
- `docs/internal/pxf_bookings.md` (PXF),
|
||||
- `docs/internal/bookings_stg_design.md` (дизайн STG),
|
||||
- этот файл (мини‑README по DAG).
|
||||
- В будущем всё это нужно будет собрать в одну понятную историю для студента:
|
||||
- отдельный раздел «Учебный пример: bookings → stg → dwh»;
|
||||
- скриншоты DAG, примеры запросов и типичные ошибки.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,342 @@
|
||||
# Plan: Подготовка main + ветка solution (Этап 4)
|
||||
|
||||
> Дата: 2026-03-12 | Версия: 4 (после третьего ревью Codex)
|
||||
> Ветка: chore/bookings-etl → main → solution
|
||||
> Спецификации кода: `docs/archive/2026-03-11_routes-to-reference.md` (п.2–5, п.8, п.10–11)
|
||||
|
||||
---
|
||||
|
||||
## 1. Мотивация
|
||||
|
||||
### Зачем раскладка по веткам
|
||||
|
||||
Сейчас весь код (эталон + студенческие задания) живёт в ветке `chore/bookings-etl`.
|
||||
Студент должен получить **частично реализованный пайплайн**: эталонный вертикальный
|
||||
срез работает «из коробки», а студенческие файлы — заглушки (`SELECT 1; -- TODO`).
|
||||
Полная реализация доступна в ветке `solution` для самопроверки.
|
||||
|
||||
### Почему именно такой порядок
|
||||
|
||||
**Рассмотренные варианты:**
|
||||
|
||||
| # | Схема | Проблема |
|
||||
|---|-------|----------|
|
||||
| A | Создать solution от bookings-etl, потом на main — cherry-pick + заглушки | Грязные cherry-pick'и, main временно «не main» |
|
||||
| B | Мерж в main, заглушки на main, solution = cherry-pick эталона обратно | main в какой-то момент содержит полный код, потом — заглушки; solution — через cherry-pick |
|
||||
| **C** | **Мерж в main → общие правки → ветка solution (снимок) → main-only правки** | **Выбран** |
|
||||
|
||||
**Почему вариант C:**
|
||||
|
||||
- **main всегда остаётся main.** Нет момента, когда main «подменяется» другой веткой.
|
||||
Любые CI/CD, ссылки, клонирования — работают непрерывно.
|
||||
- **Простая линейная история.** Мерж → общие правки → бранч → main-only коммиты.
|
||||
Никаких cherry-pick'ов.
|
||||
- **solution — архивный снимок.** Не ожидается активная разработка. Студент сверяется
|
||||
с ним, а не мержит.
|
||||
- **Общие правки до ветвления.** Документация, актуальная для обеих веток,
|
||||
обновляется *до* создания solution. Solution получает её автоматически.
|
||||
|
||||
### Стратегия синхронизации веток
|
||||
|
||||
После раскладки `main` (заглушки) и `solution` (полный код) расходятся.
|
||||
|
||||
**Главный принцип: solution — source of truth.**
|
||||
|
||||
Все изменения начинаются на solution (или feature-ветке от solution),
|
||||
затем портируются в main. Направление потока: **solution → main**.
|
||||
|
||||
Файлы делятся на три категории:
|
||||
|
||||
| Категория | Примеры | Правило |
|
||||
|-----------|---------|---------|
|
||||
| **Общий код** | STG SQL, docker, Makefile, инфраструктура | Фиксим на solution, cherry-pick в main |
|
||||
| **Общая документация** | README, TESTING, stack.md, naming_conventions | Фиксим на solution, cherry-pick в main |
|
||||
| **Branch-specific** | заглушки load/dq, fact_flight_sales_dq (ослабленный), routes_dq (без RI), ODS DAG (без airplanes→routes), branch-specific формулировки в design docs | Фиксим на нужной ветке. Конфликтов нет — содержимое файлов разное |
|
||||
|
||||
**Исключение:** main-only правка (опечатка в заглушке, битая ссылка
|
||||
в main-only документе) — правим прямо на main, solution не трогаем.
|
||||
|
||||
**Главное правило: никогда не мержим main → solution целиком.**
|
||||
|
||||
> Детальный протокол синхронизации — открытый вопрос, см. секцию 3.
|
||||
|
||||
---
|
||||
|
||||
## 2. Порядок выполнения
|
||||
|
||||
### Шаг 1. PR chore/bookings-etl → main
|
||||
|
||||
- Создать PR, ревью
|
||||
- Мерж (squash или обычный — на усмотрение)
|
||||
- После мержа: `git checkout main && git pull`
|
||||
|
||||
### Шаг 2. Общие правки на main (до ветвления solution)
|
||||
|
||||
> **Bootstrap-исключение:** стратегия синхронизации (секция 1) определяет
|
||||
> solution как source of truth с потоком solution → main. Но при первичной
|
||||
> раскладке solution ещё не существует — общие правки делаются на main,
|
||||
> и solution наследует их при ветвлении (шаг 3). После создания solution
|
||||
> действует штатный протокол.
|
||||
|
||||
Эти изменения отражают код, уже изменённый на bookings-etl
|
||||
(fact_flight_sales_load.sql использует ods.routes). Документация должна
|
||||
соответствовать коду на **обеих** ветках.
|
||||
|
||||
#### 2.1. Документация (общая для обеих веток)
|
||||
|
||||
- `docs/design/bookings_dds_design.md` — обновить описание fact lookup
|
||||
(airports через ods.routes, airplane через dim_routes point-in-time)
|
||||
- `docs/bookings_to_gp_dds.md` — обновить описание DDS lookup
|
||||
- `docs/bookings_to_gp_ods.md` — обновить описание ODS
|
||||
- `docs/design/db_schema.md` — пометить STG + ods.routes как эталон
|
||||
|
||||
#### 2.2. TODO.md — переписать
|
||||
|
||||
Текущий текст Этапа 4 в TODO.md описывает устаревшую стратегию
|
||||
(удалить routes из STG, убирать `\i` из ddl_gp.sql). Нужно обновить,
|
||||
чтобы на solution осталась корректная история:
|
||||
|
||||
- Отметить этапы 2, 3 выполненными (✅)
|
||||
- **Переписать** текст этапа 4 (не просто поставить галочку) — привести
|
||||
в соответствие с фактической стратегией (данный план)
|
||||
- Отметить этап 4 выполненным после завершения
|
||||
|
||||
### Шаг 3. Создать ветку solution
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git checkout -b solution
|
||||
git push -u origin solution
|
||||
```
|
||||
|
||||
Solution получает: полный эталонный код + актуальную общую документацию +
|
||||
все design docs, plans, archive, TODO.md — полный контекст для мейнтейнера.
|
||||
|
||||
### Шаг 4. Main-only правки (заглушки, ослабление DQ)
|
||||
|
||||
Работаем на main (или на feature-ветке → PR в main).
|
||||
|
||||
#### 4.1. Код: fact_flight_sales_dq.sql — ослабить student SK
|
||||
|
||||
Спецификация: архивный план, п.2.
|
||||
|
||||
- `passenger_sk IS NULL` → `RAISE NOTICE` (было `RAISE EXCEPTION`)
|
||||
- Разделить route-related блок: airport_sk (порог 1%, EXCEPTION) vs
|
||||
route_sk/airplane_sk (NOTICE only)
|
||||
- Добавить учебный комментарий
|
||||
|
||||
#### 4.2. Код: routes_dq.sql — закомментировать RI airplane
|
||||
|
||||
Спецификация: архивный план, п.3.
|
||||
|
||||
- **Закомментировать** (не удалять) блок RI `airplane_code → ods.airplanes`
|
||||
- Добавить комментарий:
|
||||
|
||||
```sql
|
||||
-- Проверка RI airplane_code → ods.airplanes закомментирована,
|
||||
-- т.к. таблица ods.airplanes реализуется студентом.
|
||||
-- После реализации — раскомментируйте этот блок.
|
||||
-- Полную версию см. в ветке solution.
|
||||
```
|
||||
|
||||
#### 4.3. Код: ODS DAG — убрать зависимость airplanes → routes
|
||||
|
||||
Спецификация: архивный план, п.4.
|
||||
|
||||
- `[dq_ods_airports, dq_ods_airplanes] >> load_ods_routes`
|
||||
→ `dq_ods_airports >> load_ods_routes`
|
||||
|
||||
#### 4.4. Заглушки: студенческие файлы
|
||||
|
||||
Спецификация: архивный план, п.5.
|
||||
|
||||
| Слой | Файлы (load + dq) | DDL |
|
||||
|------|--------------------|-----|
|
||||
| ODS | airplanes, seats | Оставить (таблица нужна) |
|
||||
| DDS | dim_routes, dim_passengers, dim_airplanes | Оставить (нужен для LEFT JOIN) |
|
||||
| DM | airport_traffic, route_performance, monthly_overview, passenger_loyalty | Оставить |
|
||||
|
||||
Формат заглушки load:
|
||||
```sql
|
||||
-- TODO: реализуйте загрузку (см. ТЗ в docs/assignment/analyst_spec.md)
|
||||
-- Эталонную реализацию можно найти в ветке solution.
|
||||
SELECT 1;
|
||||
```
|
||||
|
||||
Формат заглушки dq:
|
||||
```sql
|
||||
-- TODO: реализуйте проверки качества данных
|
||||
-- Эталонную реализацию можно найти в ветке solution.
|
||||
SELECT 1;
|
||||
```
|
||||
|
||||
#### 4.5. Тесты
|
||||
|
||||
Спецификация: архивный план, п.10.
|
||||
|
||||
- `test_dags_smoke.py`: убрать assert барьера `dq_ods_airplanes → dq_ods_routes`
|
||||
- `test_ods_sql_contract.py`: `SNAPSHOT_ENTITIES = ("airports", "routes")`
|
||||
(убрать airplanes, seats — их load/dq теперь заглушки)
|
||||
|
||||
#### 4.6. Документация (main-specific)
|
||||
|
||||
- `docs/design/bookings_ods_design.md` — пометить airplanes/seats как студенческие,
|
||||
убрать зависимость routes от airplanes, скорректировать DQ-контракт
|
||||
- `docs/design/bookings_dds_design.md` — обновить DQ-описание:
|
||||
student SK не блокируют (NOTICE), airport_sk — порог 1%
|
||||
- `docs/bookings_to_gp_dds.md` — обновить: student SK будут NULL на main
|
||||
- `docs/reference/qa-plan.md` — уточнить: ods.airplanes и ods.seats
|
||||
пусты by design на main
|
||||
|
||||
### Шаг 5. Очистка docs на main (после всех правок)
|
||||
|
||||
> Этот шаг выполняется **после** шагов 2–4, потому что:
|
||||
> - solution уже создан (шаг 3) и сохранил все файлы
|
||||
> - план и архивный справочник были доступны во время работы (шаг 4)
|
||||
|
||||
#### 5.1. Удалить с main (остаётся на solution)
|
||||
|
||||
| Файл/каталог | Почему не нужен студенту |
|
||||
|--------------|------------------------|
|
||||
| `docs/plans/` (весь каталог) | Планы разработки — внутренняя кухня |
|
||||
| `docs/archive/` (весь каталог) | Архив планов — внутренняя кухня |
|
||||
| `docs/design/PRD.md` | Продуктовые требования — внутренний документ |
|
||||
| `docs/design/assignment_design.md` | Мета-дизайн задания (для авторов курса, не для студентов) |
|
||||
| `docs/reference/bookings_generation_benchmark.md` | Бенчмарки генерации — отладочная информация |
|
||||
| `docs/reference/bookings_tz.md` | Заметки о таймзонах — отладочная информация |
|
||||
| `docs/reference/pxf_bookings.md` | PXF-конфигурация — внутренняя отладка |
|
||||
| `docs/agent-dag-testing.md` | Инструкция для AI-агентов по тестированию |
|
||||
| `docs/e2e-etl-test-protocol.md` | E2E-протокол — внутреннее тестирование |
|
||||
| `TODO.md` | Таск-лист мейнтейнера |
|
||||
|
||||
#### 5.2. Что остаётся на main (полезно студенту)
|
||||
|
||||
| Файл | Зачем студенту |
|
||||
|------|---------------|
|
||||
| `README.md` | Установка, запуск, структура проекта |
|
||||
| `TESTING.md` | Как проверять свою работу |
|
||||
| `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` | Если студент использует AI-помощников |
|
||||
| `docs/README.md` | Навигация по документации |
|
||||
| `docs/stack.md` | Стек технологий — контекст |
|
||||
| `docs/dag_execution_order.md` | Порядок запуска DAG'ов |
|
||||
| `docs/bookings_to_gp_stage.md` | Описание STG — эталонный код для изучения |
|
||||
| `docs/bookings_to_gp_ods.md` | Описание ODS |
|
||||
| `docs/bookings_to_gp_dds.md` | Описание DDS |
|
||||
| `docs/bookings_to_gp_dm.md` | Описание DM |
|
||||
| `docs/design/naming_conventions.md` | Нейминг полей — нужен для DDL/SQL |
|
||||
| `docs/design/db_schema.md` | Схема БД — справочник |
|
||||
| `docs/design/bookings_stg_design.md` | Дизайн STG — эталон для изучения |
|
||||
| `docs/design/bookings_ods_design.md` | Дизайн ODS — нужен для задания |
|
||||
| `docs/design/bookings_dds_design.md` | Дизайн DDS — нужен для задания |
|
||||
| `docs/design/bookings_dm_design.md` | Дизайн DM — нужен для задания |
|
||||
| `docs/assignment/` | Задание (analyst_spec.md) |
|
||||
| `docs/reference/qa-plan.md` | QA-чеклист — полезен для самопроверки |
|
||||
| `docs/reference/bookings_db_issues.md` | Известные проблемы — чтобы студент не тратил время на отладку |
|
||||
|
||||
#### 5.3. AGENTS.md — адаптировать для main
|
||||
|
||||
`AGENTS.md` содержит карту проекта и ссылки, которые побьются после cleanup.
|
||||
Нужно обновить, а не просто добавить одну строку:
|
||||
|
||||
- **Карта проекта (секция 2):** убрать упоминания `docs/plans/`, `docs/archive/`.
|
||||
Добавить: «Полные дизайн-документы (PRD, assignment_design, планы) — в ветке solution»
|
||||
- **Тестирование:** убрать ссылку на `docs/agent-dag-testing.md` (файл удалён)
|
||||
- **Прочие ссылки:** проверить, что все пути в AGENTS.md ведут на существующие файлы
|
||||
|
||||
#### 5.4. Link audit — полная проверка ссылок
|
||||
|
||||
Проверить **все** ссылки во **всех** оставшихся на main markdown-файлах,
|
||||
а не только ссылки на удалённые файлы. В документации уже есть битые ссылки
|
||||
(напр. `docs/README.md` → `design/architecture_review.md`, файл в архиве).
|
||||
|
||||
**Метод:**
|
||||
|
||||
```bash
|
||||
# 1. Найти все markdown-ссылки в оставшихся файлах
|
||||
rg -o '\[.*?\]\([^)]+\.md[^)]*\)' docs/ README.md TESTING.md AGENTS.md
|
||||
|
||||
# 2. Проверить существование каждого целевого файла
|
||||
# 3. Починить: удалить ссылку, обновить путь, или заменить на «см. ветку solution»
|
||||
```
|
||||
|
||||
Известные проблемы (минимум):
|
||||
|
||||
- `docs/README.md` — ссылки на plans/, archive/, PRD, assignment_design, architecture_review
|
||||
- `docs/assignment/README.md` — возможные ссылки на assignment_design
|
||||
- `docs/design/bookings_stg_design.md` — ссылки на внутренние reference
|
||||
- `docs/bookings_to_gp_stage.md` — ссылки на reference
|
||||
- `docs/stack.md` — ссылки на reference
|
||||
- `docs/design/db_schema.md` — ссылки на PRD, assignment_design
|
||||
- `README.md` — ссылки на TODO.md, PRD
|
||||
- `AGENTS.md` — покрыто шагом 5.3
|
||||
|
||||
### Шаг 6. Верификация main
|
||||
|
||||
```bash
|
||||
make test # smoke-тесты и контракты
|
||||
make lint # линтинг
|
||||
```
|
||||
|
||||
При поднятом стенде:
|
||||
1. `make up` → `make ddl-gp`
|
||||
2. Запустить STG → ODS → DDS → DM DAG'и
|
||||
3. Проверить: `sales_report` содержит данные с корректными аэропортами
|
||||
4. Проверить: студенческие ODS/DDS-таблицы пусты
|
||||
5. Fact DQ проходит (student SK = NULL, но DQ не блокирует)
|
||||
|
||||
### Шаг 7. Верификация solution
|
||||
|
||||
```bash
|
||||
git checkout solution
|
||||
make test
|
||||
```
|
||||
|
||||
Убедиться, что полный пайплайн работает без изменений (это снимок
|
||||
проверенного chore/bookings-etl + общие doc-правки).
|
||||
|
||||
---
|
||||
|
||||
## 3. Открытый вопрос: протокол синхронизации веток
|
||||
|
||||
> Не блокирует Этап 4. Доработать после раскладки, когда появится конкретный
|
||||
> опыт (например, при первом PXF-задании).
|
||||
|
||||
### Предварительное решение: solution как source of truth
|
||||
|
||||
Все изменения (новые задания, баг-фиксы, доработки) начинаются на solution
|
||||
или на feature-ветке от solution. Main — производная.
|
||||
|
||||
```
|
||||
solution ← feat/new-task (разработка + тесты)
|
||||
↓ merge
|
||||
solution (полный эталон, всегда рабочий)
|
||||
↓ порт
|
||||
main (cherry-pick общего + заглушки)
|
||||
```
|
||||
|
||||
**Поток по умолчанию:** solution → main (одно направление).
|
||||
|
||||
**Исключение (редко):** main-only правка (опечатка в заглушке, битая ссылка
|
||||
в main-only документе) — правим прямо на main, solution не трогаем.
|
||||
|
||||
### Что нужно доработать
|
||||
|
||||
- Как именно выглядит «порт в main»: cherry-pick, ручной перенос, чеклист?
|
||||
- Что делать при конфликтах cherry-pick?
|
||||
- Нужен ли реестр known-different файлов (заглушки vs реализации) для
|
||||
автоматической проверки синхронизации?
|
||||
- Нужен ли периодический `git diff main..solution -- <shared files>` для
|
||||
обнаружения расхождений?
|
||||
|
||||
---
|
||||
|
||||
## 4. Ключевые файлы
|
||||
|
||||
Полная таблица с ролями и ветками: архивный план, секция «Ключевые файлы».
|
||||
|
||||
Дополнительно (docs cleanup, шаг 5.1):
|
||||
|
||||
| Действие | Файлы |
|
||||
|----------|-------|
|
||||
| Удалить с main | `docs/plans/*`, `docs/archive/*`, `docs/design/PRD.md`, `docs/design/assignment_design.md`, `docs/reference/bookings_generation_benchmark.md`, `docs/reference/bookings_tz.md`, `docs/reference/pxf_bookings.md`, `docs/agent-dag-testing.md`, `docs/e2e-etl-test-protocol.md`, `TODO.md` |
|
||||
| Обновить на main | `AGENTS.md` (ссылка на solution), все .md с битыми ссылками (link audit) |
|
||||
@@ -0,0 +1,3 @@
|
||||
# Планы работ
|
||||
|
||||
Активные планы. После выполнения переносятся в [`archive/`](../archive/).
|
||||
@@ -0,0 +1,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 (нужен запуск стенда)
|
||||
@@ -0,0 +1,75 @@
|
||||
# Бенчмарк генерации bookings: параллельность и тюнинг PostgreSQL
|
||||
|
||||
> Дата: 2026-03-09
|
||||
> Железо: Intel Core i7-11800H @ 2.30GHz (8 ядер / 16 потоков), игровой ноутбук
|
||||
> Среда: WSL2, Docker Desktop, PostgreSQL 16 в контейнере
|
||||
> Данные: seed-дамп 60 дней (~500k bookings, ~180k boarding_passes, ~1.2M tickets)
|
||||
|
||||
## Контекст
|
||||
|
||||
Генератор demodb (`postgrespro/demodb`) использует очередь событий `gen.events` с обработкой через `process_queue()`. При `jobs>1` воркеры запускаются через `dblink_send_query` и конкурируют за очередь через `SELECT ... FOR UPDATE SKIP LOCKED`.
|
||||
|
||||
Задача: найти оптимальную конфигурацию для `make bookings-generate-day` (инкремент +1 день).
|
||||
|
||||
## Методика
|
||||
|
||||
- Между экспериментами: `make bookings-init BOOKINGS_JOBS=N` (восстановление из seed-дампа, ~18 сек).
|
||||
- Замер: `time make bookings-generate-day BOOKINGS_JOBS=N`.
|
||||
- Контроль: `max(book_date)` должен сдвинуться на +1 день.
|
||||
- Тюнинг PG: `ALTER SYSTEM SET` + `docker compose restart bookings-db`.
|
||||
|
||||
Параметры тюнинга PostgreSQL:
|
||||
```
|
||||
shared_buffers = 512MB (дефолт: 128MB)
|
||||
work_mem = 64MB (дефолт: 4MB)
|
||||
maintenance_work_mem = 256MB (дефолт: 64MB)
|
||||
effective_cache_size = 1GB (дефолт: 4GB)
|
||||
wal_buffers = 16MB (дефолт: -1, авто)
|
||||
checkpoint_completion_target = 0.9 (дефолт: 0.9)
|
||||
random_page_cost = 1.1 (дефолт: 4.0)
|
||||
```
|
||||
|
||||
## Результаты
|
||||
|
||||
| # | Тюнинг PG | sync_commit | Jobs | Время | vs baseline |
|
||||
|---|-----------|-------------|------|--------|--------------|
|
||||
| 1 | нет | on | 1 | 2m 48s | **baseline** |
|
||||
| 2 | нет | on | 2 | 8m 36s | 3× хуже |
|
||||
| 3 | да | on | 1 | 3m 01s | шум |
|
||||
| 4 | да | on | 2 | 8m 42s | 3× хуже |
|
||||
| 5 | да | off | 1 | 2m 50s | шум |
|
||||
| 6 | да | off | 2 | 8m 39s | 3× хуже |
|
||||
|
||||
Повторный инкремент (прогретый кэш): 5m 03s (jobs=2), не замерялся (jobs=1).
|
||||
|
||||
## Выводы
|
||||
|
||||
### 1. jobs=1 оптимален для инкрементов
|
||||
|
||||
Для генерации +1 дня параллельность через dblink **контрпродуктивна**:
|
||||
- Два воркера конкурируют за одну очередь `gen.events` через `FOR UPDATE SKIP LOCKED` — это row-level lock contention.
|
||||
- Overhead: dblink-соединения, синхронизация через `gen.stat_jobs`, polling через `dblink_is_busy()`.
|
||||
- На WSL2 дополнительно: виртуализированный I/O не масштабируется при параллельных записях.
|
||||
|
||||
При генерации с нуля (`make bookings-generate`, десятки тысяч событий) jobs>1 **может** давать прирост, но не тестировалось в этом бенчмарке.
|
||||
|
||||
### 2. Тюнинг PostgreSQL не влияет
|
||||
|
||||
Увеличение shared_buffers в 4 раза, work_mem в 16 раз и т.д. не дало измеримого эффекта. Узкое место — не буферы и не I/O, а сама логика генератора: последовательная обработка событий с COMMIT после каждого.
|
||||
|
||||
### 3. synchronous_commit=off не влияет
|
||||
|
||||
Генератор делает COMMIT после каждого события (~сотни раз за день). Ожидалось, что `synchronous_commit=off` ускорит запись WAL. Эффект не обнаружен — вероятно, WAL-буферы и так справляются при одном потоке.
|
||||
|
||||
### 4. VACUUM-ивенты — отдельная проблема
|
||||
|
||||
Генератор вставляет в очередь событие `VACUUM` каждую неделю модельного времени. Обработчик запускает `VACUUM ANALYZE` всей базы через `dblink_exec`. При 500k+ строках это занимает минуты. Решение: `DELETE FROM gen.events WHERE type = 'VACUUM'` перед `continue()` в инкрементальных скриптах.
|
||||
|
||||
## Итоговая конфигурация
|
||||
|
||||
```
|
||||
BOOKINGS_JOBS=1 # синхронно, без dblink — ~3 мин на +1 день
|
||||
BOOKINGS_INIT_DAYS=60 # seed-дамп покрывает ~34 дня бронирований + boarding_passes
|
||||
```
|
||||
|
||||
Тюнинг PostgreSQL не требуется для текущих объёмов данных.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Временное ТЗ по блоку bookings (для текущей разработки)
|
||||
|
||||
_Внутренний файл для наставника: поясняет, как устроен источник `bookings-db` и генерация данных. Студентам обычно не нужен._
|
||||
|
||||
- Контейнер `bookings-db` — отдельный сервис Postgres из `docker-compose.yml`, база по умолчанию `demo` (из upstream demodb), без переименований.
|
||||
- Доступ снаружи не блокируем (порт `5434` по умолчанию), чтобы позже читать через PXF и подключаться из Greenplum.
|
||||
- Инициализация: два способа:
|
||||
- `make bookings-init` (рекомендуется): быстрое восстановление из seed-дампа (~18 сек).
|
||||
- `make bookings-generate` (для разработчиков): полная генерация с нуля — клонирует demodb с закреплённым коммитом, накладывает патчи (`engine`: `jobs=1` синхронно + `busy()` игнорирует свой pid; `install.sql`: `DROP DATABASE IF EXISTS`, `connstr` без хардкода), ждёт `pg_isready`, ставит `gen.connstr` и GUC `bookings.start_date/init_days/jobs`, затем запускает `/bookings/generate_next_day.sql` через `psql -f`. Значения по умолчанию: стартовая дата 2017-01-01, `init_days=60`, `jobs=2`.
|
||||
- Генерация следующего дня: `make bookings-generate-day` прогоняет тот же SQL (читает GUC, вызывает `generate/continue`, ждёт `busy()`, закрывает dblink). При `jobs=1` всё синхронно, без dblink.
|
||||
- Исходники demodb: клонируем по требованию с фиксированным хешем, кладём в `bookings/demodb/` (в `.gitignore`), патчи лежат в `bookings/patches/` и применяются автоматически при `make bookings-generate`.
|
||||
- Документация: в README описаны команды (`bookings-init`, `bookings-generate`, проверка данных, генерация дня), параметры `.env`; настройка PXF/ETL — следующий этап.
|
||||
|
||||
## Текущее состояние
|
||||
- `make bookings-init` — быстрое восстановление из seed-дампа (~18 сек), рекомендуется для студентов.
|
||||
- `make bookings-generate` — полная генерация с нуля: автоматически применяет патчи (`engine_jobs1_sync.patch`, `install_drop_if_exists.patch`), ждёт готовности Postgres через `pg_isready`, запускает `install.sql`, выставляет `gen.connstr`/GUC и вызывает `generate_next_day.sql` через `psql -f`.
|
||||
- Дефолты: `BOOKINGS_START_DATE=2017-01-01`, `BOOKINGS_INIT_DAYS=60`, `BOOKINGS_JOBS=2`. При `jobs=1` генерация идёт синхронно без dblink, `busy()` не учитывает текущую сессию.
|
||||
- `.env.example`/README обновлены под новые дефолты; каталог `bookings/demodb/` в `.gitignore`.
|
||||
- Патчи лежат в `bookings/patches/` и накладываются при `bookings-clone-demodb`.
|
||||
|
||||
## Текущее состояние тестов/проблем
|
||||
- Чистый прогон `make bookings-init` (восстановление из seed-дампа) проходит за ~18 секунд.
|
||||
- Чистый прогон `make bookings-generate` (после `docker compose down -v` и удаления `bookings/demodb`) проходит за ~1,5 минуты: база ставится, `busy()` → `f`, `bookings.bookings` от `2017-01-01 00:00:18` до `2017-01-01 23:59:59`.
|
||||
- Ранее зависание на `busy()` при `jobs=1` лечится патчем: `process_queue` теперь синхронный, а `busy()` игнорирует текущий backend.
|
||||
- Данных пока только на 1 день по умолчанию, чтобы генерация не занимала много времени.
|
||||
|
||||
## Идеи/следующие шаги
|
||||
- Если понадобится больше дней — увеличивать `BOOKINGS_INIT_DAYS`, но помнить, что генерация может идти долго; контролировать через `SELECT busy();`.
|
||||
- Следующий этап — PXF/ETL в Greenplum; текущая задача — лишь подготовить источник bookings.
|
||||
@@ -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,13 +137,13 @@
|
||||
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)
|
||||
- `pxf/init/10_pxf_bookings.sh` (ensure‑логика)
|
||||
- `pxf/init/start_greenplum_with_pxf.sh` (старт контейнера)
|
||||
- `README.md` (раздел «Greenplum + PXF: свой образ»)
|
||||
- `docs/stack.md` (раздел «Greenplum + PXF: свой образ»)
|
||||
|
||||
## 10. Известная проблема: после `docker compose stop/start` Greenplum может упасть (auth для PXF)
|
||||
|
||||
@@ -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/<DAG_ID>/dagRuns" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"conf":{}}' | jq .dag_run_id
|
||||
|
||||
# Проверка статуса
|
||||
curl -s -u admin:admin \
|
||||
"http://localhost:8080/api/v1/dags/<DAG_ID>/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** (статика) — можно делать параллельно без стенда
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
# Технические детали стенда (Docker / Airflow / Greenplum)
|
||||
|
||||
Этот документ не обязателен для прохождения лабораторных, но полезен, если вы:
|
||||
|
||||
- хотите понять, как устроен стенд “под капотом”;
|
||||
- меняете конфиги/зависимости и пересобираете образы;
|
||||
- отлаживаете проблемы со стартом Greenplum/PXF или Connections в Airflow.
|
||||
|
||||
## Make (опционально, но удобно)
|
||||
|
||||
Если `make` не установлен, можно пользоваться `docker compose ...`.
|
||||
Но с `make` команды короче и проще.
|
||||
|
||||
Установка `make` (если нужно):
|
||||
|
||||
- Linux (Debian/Ubuntu): `sudo apt install -y make`
|
||||
- macOS: `brew install make`
|
||||
- Windows:
|
||||
- WSL: `sudo apt install -y make`
|
||||
- Chocolatey: `choco install make`
|
||||
- Scoop: `scoop install make`
|
||||
|
||||
Примеры:
|
||||
|
||||
```bash
|
||||
make up
|
||||
make logs
|
||||
make gp-psql
|
||||
```
|
||||
|
||||
## Airflow Connections
|
||||
|
||||
Готовые DAG по умолчанию подключаются к БД через Airflow Connections:
|
||||
|
||||
- `greenplum_conn` — Greenplum;
|
||||
- `bookings_db` — демо‑БД bookings (`bookings-db`).
|
||||
|
||||
В `docker-compose.yml` Connections задаются через переменные окружения `AIRFLOW_CONN_...`,
|
||||
поэтому они могут не отображаться в UI — для DAG это нормально.
|
||||
|
||||
Если нужно завести вручную:
|
||||
|
||||
1) Airflow UI → Admin → Connections → Add a new record
|
||||
2) Пример для Greenplum:
|
||||
- Conn Id: `greenplum_conn`
|
||||
- Conn Type: `Postgres`
|
||||
- Host: `greenplum`
|
||||
- Schema: `gp_dwh`
|
||||
- Login/Password: `gpadmin` / `gpadmin`
|
||||
- Port: `5432`
|
||||
|
||||
## PXF (в 5 строк)
|
||||
|
||||
PXF (Platform Extension Framework) — компонент Greenplum для работы с внешними источниками.
|
||||
В этом стенде PXF используется для чтения таблицы `bookings.bookings` из Postgres прямо из Greenplum
|
||||
через внешнюю таблицу `stg.bookings_ext`. Поэтому загрузка в `stg.bookings` выглядит как обычный
|
||||
`INSERT ... SELECT` без промежуточных файлов.
|
||||
|
||||
## Greenplum + PXF: свой образ
|
||||
|
||||
Greenplum собирается из собственного `Dockerfile.greenplum`, чтобы:
|
||||
|
||||
- не ловить проблемы с правами/`chown` при bind‑mount конфигов;
|
||||
- держать PXF‑конфиги и JDBC‑драйвер внутри образа;
|
||||
- делать старт контейнера идемпотентным.
|
||||
|
||||
При старте контейнера:
|
||||
|
||||
- seed‑конфиги PXF докладываются в `PXF_BASE` на persistent volume;
|
||||
- создаются каталоги `PXF_BASE/run` и `PXF_BASE/logs`;
|
||||
- расширение `pxf` создаётся автоматически, когда Greenplum становится доступен (с ретраями).
|
||||
|
||||
Полезные команды:
|
||||
|
||||
```bash
|
||||
make build # пересобрать образы
|
||||
docker compose ps # проверить health
|
||||
```
|
||||
|
||||
Перезапись seed‑файлов в `PXF_BASE`:
|
||||
|
||||
- `PXF_SEED_OVERWRITE=1` — перезаписать конфиги при старте;
|
||||
- `PXF_SYNC_ON_START=1` — выполнить `pxf cluster sync` при старте.
|
||||
|
||||
Подробнее: `docs/reference/pxf_bookings.md`.
|
||||
|
||||
## Airflow: свой образ
|
||||
|
||||
Airflow тоже собирается из собственного `Dockerfile.airflow`, чтобы зависимости ставились при сборке,
|
||||
а не во время старта контейнеров.
|
||||
|
||||
Если меняли `airflow/requirements.txt` или `Dockerfile.airflow`, пересоберите образы:
|
||||
|
||||
```bash
|
||||
make build
|
||||
make up
|
||||
```
|
||||
|
||||
## Локальное окружение разработчика (uv)
|
||||
|
||||
Локальным окружением управляет `uv` — он создаёт `.venv` из `pyproject.toml` / `uv.lock`.
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
make test
|
||||
make lint
|
||||
make fmt
|
||||
```
|
||||
|
||||
Не устанавливайте зависимости через `pip install --user ...` — это часто ломает окружение и IDE.
|
||||
|
||||
## Переменные окружения (`.env`)
|
||||
|
||||
Все настройки лежат в `.env` (шаблон: `.env.example`).
|
||||
|
||||
### Порты на хосте
|
||||
|
||||
- `AIRFLOW_WEB_PORT` — Airflow UI (по умолчанию `8080`)
|
||||
- `PGMETA_PORT` — мета‑БД Airflow (по умолчанию `5433`, обычно нужно только для диагностики)
|
||||
- `BOOKINGS_DB_PORT` — bookings-db (по умолчанию `5434`)
|
||||
- `GP_HOST_PORT` — Greenplum (по умолчанию `5435`)
|
||||
|
||||
### Greenplum
|
||||
|
||||
- `GP_USER`, `GP_PASSWORD`, `GP_DB`
|
||||
- `GP_PORT` — внутренний порт в Docker-сети (обычно `5432`)
|
||||
- `GP_HOST_PORT` — внешний порт на хосте (по умолчанию `5435`)
|
||||
|
||||
### bookings-db (Postgres)
|
||||
|
||||
- `BOOKINGS_DB_USER`, `BOOKINGS_DB_PASSWORD`
|
||||
- `BOOKINGS_DB_PORT` — внешний порт (по умолчанию `5434`)
|
||||
- `BOOKINGS_START_DATE` — стартовая дата модельного времени
|
||||
- `BOOKINGS_INIT_DAYS` — сколько дней генерировать при `make bookings-generate` (генерация с нуля)
|
||||
- `BOOKINGS_JOBS` — число джобов генератора (по умолчанию `1`)
|
||||
@@ -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`, под который уже готовы патчи (делать только вместе с обновлением документации и проверкой, что генерация стабильна).
|
||||
|
||||
@@ -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'ах.
|
||||
@@ -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`).
|
||||
@@ -27,5 +27,10 @@
|
||||
<value>bookings</value>
|
||||
</property>
|
||||
|
||||
<property>
|
||||
<name>jdbc.column.types</name>
|
||||
<value>jsonb=TEXT,tstzrange=TEXT,_int4=TEXT</value>
|
||||
</property>
|
||||
|
||||
</configuration>
|
||||
|
||||
Executable
+115
@@ -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 тест успешно завершен!"
|
||||
+2
-22
@@ -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"
|
||||
@@ -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);
|
||||
+43
-7
@@ -1,15 +1,12 @@
|
||||
-- Главный входной DDL-скрипт для Greenplum в учебном стенде.
|
||||
-- Выполняется из контейнера командой `make ddl-gp` и создаёт/обновляет
|
||||
-- все объекты, которые нужны базовым DAG (csv_to_greenplum, bookings_to_gp_stage);
|
||||
-- все объекты, которые нужны DAG'ам bookings ETL-пайплайна;
|
||||
-- подключает файловые DDL через \i, чтобы сохранять единый входной скрипт.
|
||||
--
|
||||
-- Чтобы не ломать задания, новые объекты лучше добавлять в отдельные файлы
|
||||
-- и подключать их отсюда, а существующие определения не удалять.
|
||||
--
|
||||
-- Подробнее про STG/bookings: см. docs/internal/bookings_stg_readme.md.
|
||||
|
||||
-- Таблица для CSV‑пайплайна (csv_to_greenplum).
|
||||
\i base/orders_ddl.sql
|
||||
-- Подробнее про STG/bookings: см. docs/bookings_to_gp_stage.md.
|
||||
|
||||
-- Внешняя таблица для чтения данных из демо-БД bookings через PXF (JDBC).
|
||||
-- Источник: таблица bookings.bookings в базе demo (Postgres, сервис bookings-db).
|
||||
@@ -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
|
||||
@@ -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).';
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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).';
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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).';
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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).';
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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).';
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
|
||||
@@ -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).';
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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).';
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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 приведет к двойному счету!';
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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)';
|
||||
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user