docs(all): реструктурирована документация — docs/internal/ заменён на design/, reference/, archive/, plans/

- Зачем:
  - docs/internal/ превратился в свалку: дизайн-документы, ревью, планы и справочники лежали вперемешку.
  - архивные планы были неотличимы от живых документов.
- Что:
  - docs/internal/ удалён; файлы распределены по docs/design/, docs/reference/, docs/archive/, docs/plans/, docs/assignment/.
  - educational-tasks.md убран из корня в архив (устарел).
  - обновлены все перекрёстные ссылки в AGENTS.md, TODO.md, README.md, docs/README.md и внутри design/reference/.
  - актуализированы architecture_review.md (статус DM-слоя), db_schema.md (DM-слой), TESTING.md, dag_execution_order.md, pxf_bookings.md.
  - добавлены заглушки docs/assignment/README.md и docs/plans/README.md.
- Проверка:
  - make test && make lint
  - rg 'docs/internal' --glob '!docs/archive/*' — должно быть пусто.
This commit is contained in:
ddadmin committed 2026-03-10 23:02:41 +03:00
1 parent 5972bcc2d8
commit 6655326caa
29 files changed
+322 -64

No files matched your search

+244
View File
@@ -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». Финализировать.
+301
View File
@@ -0,0 +1,301 @@
# Ревью архитектуры слоёв DWH: оценка учебной ценности
> Дата: 2026-03-01
> Статус: backlog задач для доработки
> Контекст: оценка текущей конструкции слоёв с точки зрения учебных целей
## 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**
- **Решение**: создать «маршрут изучения» DDS: tariffs → calendar → airports → passengers → routes (SCD2) → fact
- [ ] **DQ без переиспользуемых функций**
- **Решение**: добавить один опциональный пример `sql/lib/dq_assert_no_duplicates()` как seed
- [ ] **Нет документа по стратегии distribution**
- **Решение**: создать `docs/design/distribution_strategy.md` с объяснением логики для каждого слоя
- [ ] **Отсутствующие паттерны** (комментарии/заметки):
- Partitioning (когда и зачем, почему не здесь)
- SCD Type 3/6 (хотя бы упомянуть существование)
- Data lineage (сквозное трассирование `_load_id` через STG → ODS → DDS → DM)
---
## ПРИНЯТЫЕ АРХИТЕКТУРНЫЕ РЕШЕНИЯ
### 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** | ODS batch resolver: разделить логику для справочников и транзакций | 2-3 часа | ODS DAG + 5 транзакционных load-скриптов |
| ~~P0~~ | ~~Исправить distribution key в airport_traffic~~ | ~~5 мин~~ | ~~done~~ |
| ~~P1~~ | ~~Добавить 7 точечных комментариев~~ | ~~30-40 мин~~ | ~~done~~ |
| **P2** | Явный storage type + AO где нет UPDATE (ADR-3) | 2-3 часа | все `*_ddl.sql` в ods/dds/dm + 4 ODS snapshot load |
| **P2** | Рефакторинг hashdiff → TEMP TABLE | 1 час | `sql/dds/dim_routes_load.sql` |
| ~~P2~~ | ~~Переименовать STG поля в канон + заметка~~ | ~~1-2 часа~~ | ~~done~~ |
| **P2** | TEMP TABLE для сложных ODS load-ов | 1 час | 3-4 ODS load файла |
| **P2** | Реализовать `dm.route_performance` | 2-3 часа | 3 SQL + DAG + тесты |
| **P3** | Маршрут изучения DDS + distribution strategy doc | 1 час | 2 новых md-файла |
+139
View File
@@ -0,0 +1,139 @@
# Дизайн курсового задания
> Тактические решения по нарезке задания, порядку выполнения и самопроверке.
> Стратегию и контекст см. в [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` |
| STG | `bookings`, `tickets`, `segments`, `flights`, `boarding_passes`, `airports` |
### Задание студенту
| Слой | Таблицы | Что нового для студента |
|------|-------------------------------------------------------------------|--------------------------------------------------|
| STG | `airplanes`, `seats`, `routes` | Практика по аналогии с эталоном |
| ODS | `airplanes`, `seats`, `routes` | Практика SCD1 UPSERT по аналогии |
| DDS | `dim_airplanes` (SCD1), `dim_passengers` (SCD1), `dim_routes` (SCD2) | **SCD2 — ключевой вызов курсовой** |
| DM | `airport_traffic`, `monthly_overview`, `route_performance`, `passenger_loyalty` | Разная сложность (от простой к сложной) |
---
## 2. Рекомендуемый порядок выполнения
Студенту рекомендуется (но не обязательно) двигаться в таком порядке:
1. **STG** (airplanes, seats, routes) — разминка, по аналогии
2. **ODS** (airplanes, seats, routes) — закрепление UPSERT
3. **DDS** dim_airplanes, dim_passengers (SCD1) — новые измерения
4. **DDS** dim_routes (**SCD2**) — ключевой вызов
5. **DM** airport_traffic — простая витрина, похожа на sales_report
6. **DM** route_performance — TRUNCATE+INSERT, SCD2-агрегация по BK
7. **DM** monthly_overview — двухуровневая агрегация
8. **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_stg
│ ├── check_stg_airplanes_exists (таблица создана, >0 строк)
│ ├── check_stg_seats_exists
│ └── check_stg_routes_exists
├── validate_ods
│ ├── check_ods_airplanes_rowcount (ODS >= STG по кол-ву уникальных BK)
│ ├── check_ods_seats_rowcount
│ ├── check_ods_routes_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-витрины содержат данные за загруженные дни
---
## 5. Формат ТЗ от аналитика
Файл: `docs/assignment/analyst_spec.md` (или несколько файлов по слоям).
Для каждой таблицы-задания документ содержит:
- **Имя таблицы** и целевая схема (stg / ods / dds / dm)
- **Описание** — что хранит таблица, бизнес-смысл
- **Список полей** с типами и описанием
- **Маппинг источников** — откуда берётся каждое поле
- **Бизнес-правила и фильтры** (если есть)
- **Тип историзации** (SCD1 / SCD2 / snapshot / append)
- **Гранулярность** (одна строка = ?)
- **Distribution key** (подсказка или задание на выбор)
Формат — приближен к реальным ТЗ, которые студент встретит на работе.
File diff suppressed because it is too large. Load diff
+409
View File
@@ -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` для всех строк
+723
View File
@@ -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`) пустой батч допустим;
- для 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`.
+143
View File
@@ -0,0 +1,143 @@
# Дизайн STG для bookings в Greenplum
## 1. Цель и общий контур
- Источник: Postgres в контейнере `bookings-db`, база `demo`, таблица `bookings.bookings` (см. [`docs/reference/bookings_tz.md`](../reference/bookings_tz.md)).
- Цель: показываем путь данных от операционной БД до сырого слоя DWH в Greenplum.
- В этом документе описываем часть `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` (см. [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
### 2.1. Схема
- Используем одну схему `stg` в Greenplum.
- В этой схеме будут:
- внешние таблицы PXF `*_ext` для чтения из `bookings-db`;
- внутренние таблицы STG `stg.*` для долговременного хранения «сырых» данных.
### 2.2. Внешняя таблица (PXF)
- Имя таблицы: `stg.bookings_ext`.
- Назначение: «окно» в исходную таблицу `bookings.bookings` в `bookings-db` через PXF (JDBC).
- Типы колонок:
- можем использовать «родные» типы из `bookings.bookings` (включая даты/числа);
- задача внешней таблицы — корректно читать данные из источника, не заниматься приведением типов.
В текущей реализации аналогично созданы внешние таблицы `*_ext` и для остальных сущностей (см. `sql/stg/*_ddl.sql`).
DDL определён в `sql/stg/bookings_ddl.sql` и подключается из `sql/ddl_gp.sql` через `\i` (применяется через `make ddl-gp`).
### 2.3. Внутренняя таблица STG
- Имя таблицы: `stg.bookings`.
- Назначение: хранить сырые данные из источника для последующей обработки (ODS/DDS/витрины).
- Принципы моделирования:
- все бизнес‑колонки из `bookings.bookings` храним как `TEXT` (как в примерах STG из статьи);
- не делаем `UPDATE/DELETE`, только `INSERT` новых записей;
- бизнес‑колонки по названию совпадают с источником (чтобы проще было маппить).
Технологические колонки:
- `event_ts TIMESTAMP` — дата/время из источника, приведённая к TIMESTAMP:
- используется как опорная колонка для инкрементальной загрузки;
- заполняется из опорной даты/времени, принятой для конкретной сущности (например, для `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 позже можно будет ввести суррогатные ключи и нормализовать модель под витрины.
## 3. Инкрементальная загрузка
### 3.1. Опорное поле для инкремента
- Опорная колонка: `event_ts` (внутреннее имя в STG).
- Источник значения:
- для `bookings` используем `book_date` из `bookings.bookings` (в демо‑БД это поле естественно “шагает” по дням);
- при чтении через `stg.bookings_ext` приводим к `TIMESTAMP` и сохраняем в `stg.bookings.event_ts`.
### 3.2. Правила определения full/delta
- При первом запуске, если таблица `stg.bookings` пуста:
- считаем режим `full` — загружаем все строки из `stg.bookings_ext`.
- При последующих запусках:
- читаем `max(event_ts)` из `stg.bookings` за все предыдущие загрузки;
- загружаем строки, где `event_ts` больше этой максимальной метки (верхняя граница по времени не задаётся).
Таким образом, вся логика инкремента «замкнута» на один техно‑столбец `event_ts`, который студент потом сможет использовать и на следующих слоях (например, в CDC‑логике).
## 4. DAG’и Airflow (логика на уровне задач)
### 4.1. DAG для DDL
- `dag_id`: `bookings_stg_ddl` (реализован в `airflow/dags/bookings_stg_ddl.py`).
- Назначение: один раз (или при изменении схемы) создать необходимые объекты в Greenplum:
- схему `stg` (если её ещё нет);
- внешние таблицы `*_ext` и внутренние таблицы слоя `stg` для всех сущностей потока
(см. `sql/stg/*_ddl.sql`).
- Этот DAG не загружает данные, только подготавливает структуру.
- Вся DDL‑логика (CREATE/ALTER/DROP) сосредоточена здесь; рабочие DAG’и занимаются только DML (INSERT/SELECT).
### 4.2. DAG для пошаговой загрузки
- `dag_id`: `bookings_to_gp_stage`.
- Основные параметры:
- `_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"`).
Последовательность задач (упрощённая, но отражающая те же шаги):
1. `generate_bookings_day`
- PostgresOperator к `bookings-db`;
- выполняет скрипт `/sql/src/bookings_generate_day_if_missing.sql`;
- скрипт смотрит на `max(book_date)` и:
- если база пуста — берёт стартовую дату из конфигурации (`bookings.start_date`) и генерирует `bookings.init_days` суток;
- если данные уже есть — добавляет один следующий учебный день после `max(book_date)` (логика как в `bookings/generate_next_day.sql`).
2. `load_bookings_to_stg`
- PostgresOperator к Greenplum;
- выполняет скрипт `/sql/stg/bookings_load.sql`;
- внутри SQL считается `max(event_ts)` по «старым» батчам и по нему строится окно инкремента:
- первая загрузка (full) — берём все строки из `stg.bookings_ext`;
- последующие загрузки — берём только записи, где `book_date` больше предыдущего максимума (верхняя граница по дате не задаётся явно);
- при вставке заполняются тех.колонки `event_ts`, `_load_ts`, `_load_id`.
3. `check_row_counts`
- PostgresOperator к Greenplum;
- выполняет скрипт `/sql/stg/bookings_dq.sql`;
- скрипт заново считает окно инкремента по тем же правилам, что и загрузка, и сравнивает:
- количество строк в `stg.bookings_ext` с `book_date` позже «старого» максимума,
- количество строк в `stg.bookings` для текущего `_load_id`;
- при расхождении выполняет `RAISE EXCEPTION` с понятным текстом ошибки.
4. Далее — загрузка и DQ для остальных таблиц потока (tickets, справочники, транзакции).
5. `finish_summary`
- PythonOperator, который логирует итог выполнения DAG и напоминает, где смотреть детальные логи.
Таким образом, вся бизнес‑логика инкремента и проверок живёт в SQL‑скриптах, а DAG отвечает за оркестрацию и подключение к нужным БД. Для менти это хороший пример разделения ответственности между SQL и Python.
## 5. Связь с остальными документами
- [`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`).
Дальнейшая обработка данных описана в design-документах ODS/DDS/DM (см. раздел 5).
## 6. Требования к читаемости и комментариям
- DAG’и `bookings_stg_ddl` и `bookings_to_gp_stage` — это учебный материал для менти.
- В коде DAG’ов должны быть:
- понятные docstring на русском у всех функций и DAG;
- короткие комментарии рядом с нетривиальной логикой (особенно вокруг инкремента и идемпотентности);
- говорящие `task_id` и названия функций, отражающие их роль в процессе.
- Цель: чтобы по одному только коду DAG студент мог восстановить архитектуру процесса и сопоставить её с теорией из статьи про моделирование DWH.
+442
View File
@@ -0,0 +1,442 @@
# Схема БД DWH (Bookings → Greenplum)
> **Статус:** Проект в разработке. Реализованы STG, ODS и DDS (bookings).
## Обзор
Эта документация описывает архитектуру хранилища данных (DWH) для учебного проекта Airflow + Greenplum. Источник данных — демо-БД `bookings` (Postgres).
**Целевые аудитории:**
- **LLM/Разработчики**: Технические спецификации для реализации (см. раздел "Спецификации для реализации")
- **Студенты**: Обучающие материалы и пояснения (см. раздел "Обучающие материалы")
---
## Спецификации для реализации (для LLM и разработчиков)
### Ключевые договорённости
- **Источник**: используем основные таблицы схемы `bookings` (табличные данные, не `VIEW`)
- **Зерно факта `dds.fact_flight_sales`**: 1 строка = 1 сегмент билета (`ticket_no` + `flight_id`, источник: `segments`)
- **Обязательная связь для аэропортов и самолёта**: `flights.route_no → routes → (departure_airport, arrival_airport, airplane_code)`
- **Маршруты в DDS**: используем `dds.dim_routes` (SCD2), в факт пишем `route_sk` через point-in-time lookup на дату вылета
- **Даты**: как минимум различаем `book_date` (дата покупки) и `scheduled_departure` (дата/время вылета)
- **Инкремент в STG**: для `tickets` опорная дата берётся из `bookings.book_date`, потому что в `tickets` нет собственного поля времени изменения
- **DQ-проверки**: проверки качества данных выполняем SQL-скриптами, но **не сохраняем результаты в отдельные таблицы/слой DQ** (при проблемах падаем с понятной ошибкой и останавливаем пайплайн)
- **Нейминг полей**: единый стандарт — в [`docs/design/naming_conventions.md`](naming_conventions.md)
### Статус реализации по слоям
| Слой | Статус | Реализовано |
|------|--------|-------------|
| **Source** | ✅ Готово | Демо-БД bookings (Postgres) |
| **STG** | ✅ Готово | 9 из 9 таблиц (bookings, tickets, airports, airplanes, routes, seats, flights, segments, boarding_passes) |
| **ODS** | ✅ Готово | 9 из 9 таблиц + DAG `bookings_ods_ddl` и `bookings_to_gp_ods` |
| **DDS** | ✅ Готово | 6 измерений + 1 факт + DAG `bookings_dds_ddl` и `bookings_to_gp_dds` |
| **DM** | ✅ Готово | 5 витрин (sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview) + DAG `bookings_dm_ddl` и `bookings_to_gp_dm` |
### Архитектура слоёв
#### STG (Staging Layer)
- **Назначение**: Сырой слой, максимально близкий к источнику, без бизнес-логики
- **Хранение**: AO-Row (Append-Only Row-oriented) для эффективной загрузки больших объёмов
- **Типы данных**: Бизнес-колонки как `TEXT`, тех.колонки как `TIMESTAMP`
- **Инкрементальная загрузка**: Опорное поле `event_ts` (из `book_date` для tickets)
- **Технологические колонки**:
- `event_ts TIMESTAMP` — дата/время из источника для инкремента
- `_load_ts TIMESTAMP NOT NULL DEFAULT now()` — когда запись была загружена
- `_load_id TEXT` — идентификатор пачки/батча (например `{{ run_id }}`)
- **DQ-проверки (после загрузки STG)**: отдельные SQL-скрипты, которые валидируют данные (counts, дубли, NULL, orphan records) и при ошибке делают `RAISE EXCEPTION`; примеры: `sql/stg/bookings_dq.sql`, `sql/stg/tickets_dq.sql`
#### ODS (Operational Data Store)
- **Назначение**: Очищенные данные в 3NF, готовые для аналитики
- **Хранение**: Heap для частых чтений и обновлений
- **Трансформации**: Очистка, приведение типов, нормализация, SCD1 UPSERT
- **Связи**: Все связи через бизнес-ключи (без суррогатных ключей)
- **Текущий статус**: Реализован (9 таблиц, SQL DQ, DAG загрузки)
#### DDS (Data Delivery System)
- **Назначение**: Star Schema для аналитики и отчётности
- **Хранение**: Heap или AO-CO (Append-Only Column-oriented) для аналитических запросов
- **Структура**: Измерения (Dimensions) + Факты (Facts)
- **Ключи**: Суррогатные ключи (SK) для измерений, FK в фактах
- **Текущий статус**: Реализован (6 измерений, 1 факт, SQL DQ, DAG загрузки)
### Измерения DDS (Dimensions)
| Измерение | Бизнес-ключ | Суррогатный ключ | Атрибуты |
|-----------|-------------|------------------|----------|
| `dds.dim_calendar` | `date_actual` | `calendar_sk` | `year_actual`, `month_actual`, `day_actual`, `day_of_week`, `day_name`, `is_weekend` |
| `dds.dim_airports` | `airport_code` (`airport_bk`) | `airport_sk` | `airport_name`, `city`, `country`, `timezone`, `coordinates` |
| `dds.dim_airplanes` | `airplane_code` (`airplane_bk`) | `airplane_sk` | `model`, `range_km`, `speed_kmh`, `total_seats` |
| `dds.dim_tariffs` | `fare_conditions` | `tariff_sk` | `fare_conditions` |
| `dds.dim_passengers` | `passenger_id` (`passenger_bk`) | `passenger_sk` | `passenger_name` (SCD1) |
| `dds.dim_routes` | `route_no` (`route_bk`) | `route_sk` | `departure_airport`, `arrival_airport`, `airplane_code`, `departure_city` (денормализовано), `arrival_city` (денормализовано), `airplane_model` (денормализовано), `total_seats` (денормализовано), `hashdiff`, `valid_from`, `valid_to` (SCD2) |
### Факт DDS (Fact)
`dds.fact_flight_sales`:
- **Зерно**: 1 строка = 1 сегмент билета (`ticket_no` + `flight_id`)
- **FK на измерения**:
- `calendar_sk` — ссылка на дату вылета
- `departure_airport_sk` — аэропорт вылета
- `arrival_airport_sk` — аэропорт прилёта
- `airplane_sk` — самолёт
- `tariff_sk` — тариф
- `passenger_sk` — пассажир
- `route_sk` — версия маршрута (SCD2, point-in-time)
- **Метрики**:
- `price NUMERIC` — стоимость сегмента
- `is_boarded BOOLEAN` — сел ли пассажир в самолёт (из boarding_passes)
- **Атрибуты**:
- `book_ref TEXT` — бизнес-ключ бронирования
- `book_date DATE` — дата покупки
- `ticket_no TEXT` — номер билета
- `flight_id INT` — ID рейса
- `seat_no TEXT` — место (если есть)
---
### Детальное описание таблиц STG слоя
#### stg.bookings (транзакции, инкремент)
- **Источник:** `bookings.bookings` (через PXF)
- **Ключ распределения:** `book_ref`
- **Бизнес-колонки:**
- `book_ref TEXT` - номер бронирования
- `book_date TEXT` - дата бронирования
- `total_amount TEXT` - общая сумма
- **Технические колонки:** `event_ts` (=book_date), `_load_ts`, `_load_id`
- **Стратегия загрузки:** Инкремент по `book_date`
- **DQ проверки:** count (окно инкремента, пустое окно допустимо), дубликаты book_ref, NULL обязательных полей
#### stg.tickets (транзакции, инкремент)
- **Источник:** `bookings.tickets` (через PXF)
- **Ключ распределения:** `book_ref`
- **Примечание:** JOIN `tickets` ↔ `segments`/`boarding_passes` по `ticket_no` может требовать motion (ключи распределения разные).
- **Бизнес-колонки:**
- `ticket_no TEXT` - номер билета
- `book_ref TEXT` - номер бронирования
- `passenger_id TEXT` - идентификатор пассажира
- `passenger_name TEXT` - имя пассажира
- `outbound TEXT` - направление (в источнике boolean)
- **Технические колонки:** `event_ts` (из book_date через bookings), `_load_ts`, `_load_id`
- **Стратегия загрузки:** Инкремент по `book_date` (через bookings)
- **DQ проверки:** count (окно инкремента, пустое окно допустимо), дубликаты ticket_no, NULL обязательных полей, пустой passenger_name, ссылочная целостность (bookings)
#### stg.airports (справочник, full load)
- **Источник:** `bookings.airports_data` (через PXF)
- **Ключ распределения:** `airport_code`
- **Бизнес-колонки:**
- `airport_code TEXT` - код аэропорта
- `airport_name TEXT` - название аэропорта (из JSONB)
- `city TEXT` - город (из JSONB)
- `country TEXT` - страна (из JSONB)
- `coordinates TEXT` - координаты
- `timezone TEXT` - часовой пояс
- **Технические колонки:** `event_ts` (=now()), `_load_ts`, `_load_id`
- **Стратегия загрузки:** Full load (все строки при каждом запуске)
- **DQ проверки:** count, дубликаты airport_code, NULL обязательных полей
#### stg.airplanes (справочник, full load)
- **Источник:** `bookings.airplanes_data` (через PXF)
- **Ключ распределения:** `airplane_code`
- **Бизнес-колонки:**
- `airplane_code TEXT` - код самолёта
- `model TEXT` - модель (из JSONB)
- `range TEXT` - дальность полёта
- `speed TEXT` - скорость
- **Технические колонки:** `event_ts` (=now()), `_load_ts`, `_load_id`
- **Стратегия загрузки:** Full load
- **DQ проверки:** count, дубликаты airplane_code, NULL обязательных полей
#### stg.routes (справочник, full load)
- **Источник:** `bookings.routes` (через PXF)
- **Ключ распределения:** `route_no`
- **Примечание:** JOIN по `departure_airport`/`arrival_airport`/`airplane_code` может требовать motion (ключи распределения разные).
- **Бизнес-колонки:**
- `route_no TEXT` - номер маршрута
- `validity TEXT` - период действия (из tstzrange)
- `departure_airport TEXT` - аэропорт вылета
- `arrival_airport TEXT` - аэропорт прилёта
- `airplane_code TEXT` - код самолёта
- `days_of_week TEXT` - дни недели (из int[])
- `scheduled_time TEXT` - плановое время
- `duration TEXT` - длительность
- **Технические колонки:** `event_ts` (=now()), `_load_ts`, `_load_id`
- **Стратегия загрузки:** Full load
- **DQ проверки:** count, дубликаты (route_no, validity), NULL обязательных полей, ссылочная целостность (_load_id = текущий батч)
#### stg.seats (справочник, full load)
- **Источник:** `bookings.seats` (через PXF)
- **Ключ распределения:** `airplane_code` (co-location с airplanes)
- **Бизнес-колонки:**
- `airplane_code TEXT` - код самолёта
- `seat_no TEXT` - номер места
- `fare_conditions TEXT` - класс обслуживания
- **Технические колонки:** `event_ts` (=now()), `_load_ts`, `_load_id`
- **Стратегия загрузки:** Full load
- **DQ проверки:** count, дубликаты (airplane_code, seat_no), NULL обязательных полей, ссылочная целостность (_load_id = текущий батч)
#### stg.flights (транзакции, инкремент)
- **Источник:** `bookings.flights` (через PXF)
- **Ключ распределения:** `flight_id`
- **Примечание:** JOIN с таблицами, распределёнными по другим ключам, может требовать motion.
- **Бизнес-колонки:**
- `flight_id TEXT` - идентификатор рейса
- `route_no TEXT` - номер маршрута
- `status TEXT` - статус
- `scheduled_departure TEXT` - плановое время вылета
- `scheduled_arrival TEXT` - плановое время прилёта
- `actual_departure TEXT` - фактическое время вылета
- `actual_arrival TEXT` - фактическое время прилёта
- **Технические колонки:** `event_ts` (=scheduled_departure), `_load_ts`, `_load_id`
- **Стратегия загрузки:** Инкремент по `scheduled_departure`
- **DQ проверки:** count (окно инкремента, пустое окно допустимо), дубликаты flight_id, NULL обязательных полей, ссылочная целостность (routes, _load_id = текущий батч)
#### stg.segments (транзакции, инкремент)
- **Источник:** `bookings.segments` (через PXF)
- **Ключ распределения:** `ticket_no` (co-location с boarding_passes)
- **Примечание:** JOIN `segments` ↔ `tickets` по `ticket_no` может требовать motion (stg.tickets распределена по `book_ref`).
- **Бизнес-колонки:**
- `ticket_no TEXT` - номер билета
- `flight_id TEXT` - идентификатор рейса
- `fare_conditions TEXT` - класс обслуживания
- `price TEXT` - цена
- **Технические колонки:** `event_ts` (из book_date через tickets), `_load_ts`, `_load_id`
- **Стратегия загрузки:** Инкремент по `book_date` (через tickets)
- **DQ проверки:** count (окно инкремента, пустое окно допустимо), дубликаты (ticket_no, flight_id), NULL обязательных полей, ссылочная целостность (tickets, flights)
#### stg.boarding_passes (транзакции, full snapshot)
- **Источник:** `bookings.boarding_passes` (через PXF)
- **Ключ распределения:** `ticket_no` (co-location с segments)
- **Примечание:** JOIN `boarding_passes` ↔ `tickets` по `ticket_no` может требовать motion (stg.tickets распределена по `book_ref`).
- **Бизнес-колонки:**
- `ticket_no TEXT` - номер билета
- `flight_id TEXT` - идентификатор рейса
- `seat_no TEXT` - номер места
- `boarding_no TEXT` - номер посадки
- `boarding_time TEXT` - время посадки
- **Технические колонки:** `event_ts` (=now()), `_load_ts`, `_load_id`
- **Стратегия загрузки:** Full snapshot (все строки при каждом запуске)
- **DQ проверки:** count, дубликаты (ticket_no, flight_id), NULL обязательных полей, ссылочная целостность
---
## Полная схема потоков данных (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;
%% 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, Heap)
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
%% Dimensions
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
FACT_Sales[dds.fact_flight_sales]:::fact
end
%% Transformations ODS to DDS
%% Form reference tables
ODS_Airports --> DIM_Airports
ODS_Airplanes --> DIM_Airplanes
ODS_Seats -.->|Enrich total_seats| DIM_Airplanes
ODS_Segments -.->|Extract distinct| DIM_Tariffs
ODS_Tickets -->|Extract Unique| DIM_Passengers
ODS_Routes -->|SCD2 with hashdiff| DIM_Routes
DIM_Airports -.->|Enrich cities| DIM_Routes
DIM_Airplanes -.->|Enrich model and seats| DIM_Routes
%% Fact assembly (Main process + route point-in-time)
ODS_Segments -->|Main Stream| FACT_Sales
ODS_Tickets -->|Join book_ref passenger_id| FACT_Sales
ODS_Bookings -->|Join book_date| FACT_Sales
ODS_Flights -->|Join Times Status Route No| FACT_Sales
ODS_Boarding -->|LEFT JOIN Seat No| FACT_Sales
%% Link dimensions to fact
DIM_Calendar -->|calendar_sk| FACT_Sales
DIM_Airports -->|departure_airport_sk| FACT_Sales
DIM_Airports -->|arrival_airport_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
```
---
## Обучающие материалы (для студентов)
### Глоссарий ключевых терминов
| Термин | Объяснение |
|--------|-----------|
| **Зерно факта (Fact Grain)** | Минимальная единица измерения в факте. Для `dds.fact_flight_sales` — это один сегмент билета. |
| **Суррогатный ключ (Surrogate Key, SK)** | Технический ключ (обычно INT), который генерируется в DWH и не зависит от бизнес-ключа. |
| **Бизнес-ключ (Business Key)** | Ключ из источника (например, `airport_code`, `passenger_id`). |
| **Star Schema** | Модель данных, где факт в центре, а измерения вокруг него (как звезда). |
| **SCD Type 1** | Slowly Changing Dimension Type 1: при изменении данных просто перезаписываем старую запись. |
| **SCD Type 2** | Slowly Changing Dimension Type 2: при изменении данных создаём новую запись с датой начала/действия. |
| **AO-Row** | Append-Only Row-oriented: хранение данных по строкам, только добавление (без UPDATE/DELETE). |
| **Heap** | Обычное хранение данных (как в обычной таблице), поддерживает UPDATE/DELETE. |
| **Инкрементальная загрузка** | Загрузка только новых/изменённых данных за период, а не всей таблицы. |
### Пояснения к схеме
Эта диаграмма покрывает основные таблицы источника и показывает логику их трансформации. Вот на что стоит обратить внимание при обучении:
#### 1. Ветка справочников (Reference Data)
**`seats` + `airplanes` → `dds.dim_airplanes`**: Здесь мы показываем пример **обогащения**. Таблица `seats` сама по себе в аналитике редко нужна отдельной сущностью. Мы используем её в ODS, чтобы посчитать общее количество мест (`total_seats`) и добавить это как атрибут в измерение самолётов (`dds.dim_airplanes`).
**`airports` → `dds.dim_airports`**: Простой перенос (1-в-1), но в DDS мы можем добавить, например, поле `city_ru` и `city_en` как отдельные колонки, убрав JSON, который есть в источнике.
#### 2. Ветка генерации измерений (Dimension Generation)
**`tickets` → `dds.dim_passengers`**: Это самая сложная трансформация для измерения. В источнике нет таблицы "Пассажиры". Мы должны объяснить студентам, что мы "майним" пассажиров из билетов. Важно: один и тот же пассажир может иметь разные записи с разными именами (опечатки, изменение фамилии), поэтому в проде часто делают логику SCD Type 2 для отслеживания изменений.
- Для домашки (и первого эталонного решения) обычно достаточно **SCD Type 1**: одна актуальная запись на `passenger_id`, а SCD2 можно оставить как усложнение.
**`segments` → `dds.dim_tariffs`**: Таблицы тарифов физически нет в источнике, она хранится строкой (`fare_conditions`: Economy/Comfort/Business) в таблице `segments`. Мы выносим её в отдельный справочник (нормализация), чтобы в факте хранить маленький `INT` ключ, а не длинную строку.
**`routes` → `dds.dim_routes` (SCD2)**: Это отдельный учебный пример историзации. По `route_no` храним версии маршрута с `valid_from/valid_to` и `hashdiff`, чтобы показать студентам паттерн SCD2 на практике.
#### 3. Сборка Факта (`dds.fact_flight_sales`)
Это центр звезды. Мы собираем его из шести ODS таблиц:
1. **`ods.segments`**: Основа (зерно факта — один полётный сегмент билета). Дает стоимость (`price`).
2. **`ods.tickets`**: Приджойниваем, чтобы получить `book_ref` и `passenger_id`.
3. **`ods.bookings`**: Приджойниваем по `book_ref`, чтобы получить `book_date` (дата покупки).
4. **`ods.flights`**: Приджойниваем, чтобы получить расписание/факт времени и статус рейса, а также `route_no` (связка на маршруты).
5. **`dds.dim_routes`**: По `route_no` и дате вылета подбираем версию маршрута (point-in-time) и получаем `route_sk`.
6. **`ods.boarding_passes`**: Приджойниваем (LEFT JOIN), чтобы узнать, **сел ли пассажир реально в самолёт** и на какое место (`seat_no`). Это важный бизнес-аспект: билет куплен, но посадочный не выдан = пассажир не летел.
#### 4. Почему нет `dds.dim_bookings`?
В классической Star Schema измерения — это справочники (airports, airplanes, passengers), а факты — транзакции/события (sales, bookings).
`bookings` — это транзакционная таблица, а не справочник. Вместо отдельного измерения `dds.dim_bookings` мы храним:
- `book_ref` — бизнес-ключ бронирования (в факте)
- `book_date` — дата бронирования (в факте, берём из `ods.bookings` по `book_ref`)
Это позволяет отвечать на вопросы типа: *"За сколько дней до вылета люди обычно покупают билеты?"* (разница между `book_date` и датой вылета из `dds.dim_calendar`).
#### 5. Суррогатные ключи (Surrogate Keys)
В Star Schema факт должен ссылаться на суррогатные ключи (SK) измерений, а не на бизнес-ключи:
| Бизнес-ключ | Суррогатный ключ | Преимущество |
|-------------|------------------|--------------|
| `airport_code CHAR(3)` | `airport_sk INT` | Меньший размер, стабильность |
| `airplane_code TEXT` | `airplane_sk INT` | Меньший размер, стабильность |
| `passenger_id TEXT` | `passenger_sk INT` | Меньший размер, отслеживание изменений |
---
## Связанные документы
- [`docs/design/bookings_stg_design.md`](bookings_stg_design.md) — Детальный дизайн STG слоя для bookings
- [`docs/design/bookings_ods_design.md`](bookings_ods_design.md) — Детальный дизайн ODS слоя (SCD1, batch contract, DQ)
- [`docs/design/bookings_dds_design.md`](bookings_dds_design.md) — План реализации DDS слоя (Star Schema, SCD2 для routes)
- [`docs/bookings_to_gp_dds.md`](../bookings_to_gp_dds.md) — Запуск и проверка DAG `bookings_to_gp_dds`
- [`docs/archive/bookings_stg_code_review.md`](../archive/bookings_stg_code_review.md) — Ревью решения и рекомендации по улучшению
- [`docs/reference/bookings_tz.md`](../reference/bookings_tz.md) — Работа с часовыми поясами в источнике
- [`docs/reference/pxf_bookings.md`](../reference/pxf_bookings.md) — Настройка PXF для чтения из bookings-db
- [`TESTING.md`](../../TESTING.md) — Пошаговый чек-лист для тестирования стенда
---
## История изменений
| Дата | Версия | Описание изменений |
|------|--------|-------------------|
| 2026-02-25 | 2.2 | Реализован DDS: добавлены `sql/dds/*` (DDL/LOAD/DQ), DAG `bookings_dds_ddl`, DAG `bookings_to_gp_dds`, обновлены smoke-тесты и документация. |
| 2026-02-23 | 2.1 | Актуализирован статус: STG+ODS реализованы. Обновлены DDS-объекты (`dds.dim_*`, `dds.fact_flight_sales`), добавлен `dds.dim_routes` (SCD2), исправлены диаграмма и TODO. |
| 2025-01-17 | 2.0 | Удалён слой DQ для упрощения учебного стенда. Добавлены спецификации для LLM и обучающие материалы для студентов. Добавлен глоссарий терминов. |
| 2025-01-17 | 1.1 | Исправлены названия таблиц (`aircrafts_data` → `airplanes_data`, `ticket_flights` → `segments`), удалено `dim.bookings`, добавлены суррогатные ключи, добавлен слой DQ, исправлены связи |
| 2025-01-XX | 1.0 | Первоначальная версия |
+66
View File
@@ -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`.