Files
airflow-greenplum/docs/internal/bookings_dds_design.md
T
ddadminandClaude Opus 4.6 3ddf3cc136 fix(dwh): исправлены 5 замечаний ревью плана DDS
- Зачем:
  - устранить противоречия и пробелы в плане DDS перед реализацией.
- Что:
  - HIGH: факт теперь использует point-in-time SCD2 lookup для routes,
    UPDATE факта не перезаписывает dimension SK (историчность сохранена).
  - MEDIUM: явная политика NULL FK в факте (3 группы: обязательные,
    зависят от маршрута, зависят от расписания) + DQ с порогом 1%.
  - MEDIUM: добавлены DQ-проверки SCD2 интервалов (valid_from < valid_to,
    нет перекрытий версий).
  - MEDIUM: DQ факта теперь проверяет calendar_sk и route_sk IS NULL
    с порогом.
  - LOW: добавлен стабильный tie-breaker (ticket_no) при дедупликации
    passengers.
  - добавлена секция backfill/reprocess политики.
- Проверка:
  - cat docs/internal/bookings_dds_design.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:13:11 +03:00

988 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DDS Layer: план реализации Star Schema для bookings
## Контекст
STG (9 таблиц, TEXT, append-only) и ODS (9 таблиц, типизированные, SCD1) уже реализованы.
Этот план фиксирует реализацию DDS-слоя: Star Schema с измерениями и таблицей фактов.
Формат плана аналогичен `docs/internal/bookings_ods_design.md` — достаточно детальный,
чтобы реализация была однозначной.
---
## 1) Принятые архитектурные решения
| Решение | Выбор | Обоснование |
|---------|-------|-------------|
| Схема БД | Единая `dds` (`dds.dim_*`, `dds.fact_*`) | Проще для студентов, один CREATE SCHEMA |
| Суррогатные ключи | UPSERT + `MAX(sk) + ROW_NUMBER()` | Стабильные SK, Greenplum не поддерживает SERIAL |
| SCD2 | `dim_routes` с hashdiff | Реальная история в данных, классический SCD2 паттерн |
| Остальные измерения | SCD1 UPSERT | Стабильные SK для инкрементального факта |
| Загрузка факта | Инкрементальный UPSERT по `(ticket_no, flight_id)` | Консистентно с ODS, учебная ценность |
| `_load_id` в DDS | `{{ run_id }}` (Airflow run_id) | Не привязан к stg_batch_id, DDS читает current state ODS |
---
## 2) Что создаём
### Измерения (6 штук)
| Таблица | Бизнес-ключ | SK | Тип | Источник ODS |
|---------|-------------|-----|-----|-------------|
| `dds.dim_calendar` | `date_actual` | `calendar_sk` | Статическая (generate_series) | — |
| `dds.dim_airports` | `airport_code``airport_bk` | `airport_sk` | SCD1 UPSERT | `ods.airports` |
| `dds.dim_airplanes` | `airplane_code``airplane_bk` | `airplane_sk` | SCD1 UPSERT | `ods.airplanes` + `ods.seats` (total_seats) |
| `dds.dim_tariffs` | `fare_conditions` | `tariff_sk` | SCD1 UPSERT | `ods.segments` (DISTINCT) |
| `dds.dim_passengers` | `passenger_id``passenger_bk` | `passenger_sk` | SCD1 UPSERT | `ods.tickets` (дедупликация по passenger_id) |
| `dds.dim_routes` | `route_no``route_bk` | `route_sk` | **SCD2** (hashdiff) | `ods.routes` (последняя версия по validity) |
### Факт (1 штука)
| Таблица | Зерно | FK на измерения |
|---------|-------|-----------------|
| `dds.fact_flight_sales` | `(ticket_no, flight_id)` — 1 сегмент билета | `calendar_sk`, `departure_airport_sk`, `arrival_airport_sk`, `airplane_sk`, `tariff_sk`, `passenger_sk`, `route_sk` |
---
## 3) DDL таблиц
### 3.1. dds.dim_calendar
```sql
calendar_sk INTEGER NOT NULL
date_actual DATE NOT NULL
year_actual INTEGER NOT NULL
month_actual INTEGER NOT NULL
day_actual INTEGER NOT NULL
day_of_week INTEGER NOT NULL -- 1=Пн .. 7=Вс (ISO)
day_name TEXT NOT NULL -- Monday, Tuesday, ...
is_weekend BOOLEAN NOT NULL
DISTRIBUTED BY (calendar_sk)
```
Статическая, заполняется один раз (2016-01-01 .. 2030-12-31). Без `_load_id`/`_load_ts`.
### 3.2. dds.dim_airports
```sql
airport_sk INTEGER NOT NULL
airport_bk TEXT NOT NULL -- airport_code
airport_name TEXT NOT NULL
city TEXT NOT NULL
country TEXT NOT NULL
timezone TEXT NOT NULL
coordinates TEXT
created_at TIMESTAMP NOT NULL DEFAULT now()
updated_at TIMESTAMP NOT NULL DEFAULT now()
_load_id TEXT NOT NULL
_load_ts TIMESTAMP NOT NULL DEFAULT now()
DISTRIBUTED BY (airport_sk)
```
### 3.3. dds.dim_airplanes
```sql
airplane_sk INTEGER NOT NULL
airplane_bk TEXT NOT NULL -- airplane_code
model TEXT NOT NULL
range_km INTEGER
speed_kmh INTEGER
total_seats INTEGER -- COUNT(*) из ods.seats
created_at TIMESTAMP NOT NULL DEFAULT now()
updated_at TIMESTAMP NOT NULL DEFAULT now()
_load_id TEXT NOT NULL
_load_ts TIMESTAMP NOT NULL DEFAULT now()
DISTRIBUTED BY (airplane_sk)
```
### 3.4. dds.dim_tariffs
```sql
tariff_sk INTEGER NOT NULL
fare_conditions TEXT NOT NULL -- business key = fare_conditions
created_at TIMESTAMP NOT NULL DEFAULT now()
updated_at TIMESTAMP NOT NULL DEFAULT now()
_load_id TEXT NOT NULL
_load_ts TIMESTAMP NOT NULL DEFAULT now()
DISTRIBUTED BY (tariff_sk)
```
### 3.5. dds.dim_passengers
```sql
passenger_sk INTEGER NOT NULL
passenger_bk TEXT NOT NULL -- passenger_id
passenger_name TEXT NOT NULL
created_at TIMESTAMP NOT NULL DEFAULT now()
updated_at TIMESTAMP NOT NULL DEFAULT now()
_load_id TEXT NOT NULL
_load_ts TIMESTAMP NOT NULL DEFAULT now()
DISTRIBUTED BY (passenger_sk)
```
### 3.6. dds.dim_routes (SCD2)
```sql
route_sk INTEGER NOT NULL
route_bk TEXT NOT NULL -- route_no (бизнес-ключ)
departure_airport TEXT NOT NULL
arrival_airport TEXT NOT NULL
airplane_code TEXT NOT NULL
days_of_week TEXT
departure_time TIME
duration INTERVAL
hashdiff TEXT NOT NULL -- md5 хэш атрибутов для детекта изменений
valid_from DATE NOT NULL -- начало действия версии
valid_to DATE -- конец действия (NULL = текущая)
created_at TIMESTAMP NOT NULL DEFAULT now()
updated_at TIMESTAMP NOT NULL DEFAULT now()
_load_id TEXT NOT NULL
_load_ts TIMESTAMP NOT NULL DEFAULT now()
DISTRIBUTED BY (route_sk)
```
Поле `validity` из ODS не переносится как отдельная колонка — DWH сам управляет
версиями через `hashdiff` + `valid_from`/`valid_to` (классический SCD2).
Из ODS берём последнюю версию по `route_no` (ORDER BY validity DESC) как "текущее состояние".
### 3.7. dds.fact_flight_sales
```sql
-- FK на измерения (суррогатные ключи)
calendar_sk INTEGER
departure_airport_sk INTEGER
arrival_airport_sk INTEGER
airplane_sk INTEGER
tariff_sk INTEGER
passenger_sk INTEGER
route_sk INTEGER
-- Дегенеративные измерения
book_ref TEXT NOT NULL
ticket_no TEXT NOT NULL
flight_id INTEGER NOT NULL
book_date DATE
seat_no TEXT
-- Метрики
price NUMERIC(10,2)
is_boarded BOOLEAN NOT NULL
-- Служебные
_load_id TEXT NOT NULL
_load_ts TIMESTAMP NOT NULL DEFAULT now()
DISTRIBUTED BY (ticket_no)
```
### 3.8. Политика NULL FK в факте
FK суррогатные ключи разделены на три группы:
| Группа | FK | NULL допустим? | Причина |
|--------|-----|---------------|---------|
| **Обязательные** | `tariff_sk`, `passenger_sk` | Нет | Данные всегда есть в ODS (segments, tickets). NULL = баг загрузки. |
| **Зависят от маршрута** | `route_sk`, `departure_airport_sk`, `arrival_airport_sk`, `airplane_sk` | Нет (в норме) | Маршрут должен быть в ODS. NULL = аномалия данных, DQ предупреждает. |
| **Зависят от расписания** | `calendar_sk` | Допустим (редко) | `scheduled_departure` может быть NULL в ODS. DQ считает и логирует, но не фейлит. |
DQ-проверки явно контролируют каждую группу (см. секцию 6).
---
## 4) Нейминг служебных полей (консистентно с naming_conventions.md)
Источник правил: [`docs/internal/naming_conventions.md`](naming_conventions.md).
В DDS используем:
- `_load_id TEXT NOT NULL` — идентификатор загрузки (`{{ run_id }}` Airflow);
- `_load_ts TIMESTAMP NOT NULL DEFAULT now()` — время загрузки в DDS;
- `created_at TIMESTAMP NOT NULL DEFAULT now()` — когда строка создана в таблице;
- `updated_at TIMESTAMP NOT NULL DEFAULT now()` — когда строка обновлена;
- `valid_from DATE NOT NULL` — начало действия версии SCD2;
- `valid_to DATE` — конец действия SCD2 (`NULL` = текущая версия);
- `hashdiff TEXT NOT NULL` — md5 хэш атрибутов для детекта изменений SCD2;
- `*_bk TEXT` — бизнес-ключ измерения (суффикс `_bk`);
- `*_sk INTEGER` — суррогатный ключ измерения (суффикс `_sk`).
### 4.1. Почему `{{ run_id }}` вместо `stg_batch_id`
DDS читает **текущее состояние ODS** (ODS = SCD1, current state). Привязка к stg_batch_id
не требуется. `_load_id` в DDS = Airflow run_id текущего запуска DDS DAG — для аудита
"когда и каким запуском были загружены данные в DDS".
---
## 5) SQL-паттерны загрузки
> **Стиль SQL:** CTE (Common Table Expressions) — как в ODS.
### 5.1. dim_calendar — статическая, INSERT если пуста
```sql
-- Загрузка DDS dim_calendar: статическое измерение (генерация дат).
-- Заполняем только если таблица пуста (идемпотентно).
INSERT INTO dds.dim_calendar (
calendar_sk, date_actual, year_actual, month_actual,
day_actual, day_of_week, day_name, is_weekend
)
SELECT
ROW_NUMBER() OVER (ORDER BY d.date_actual)::INTEGER AS calendar_sk,
d.date_actual,
EXTRACT(YEAR FROM d.date_actual)::INTEGER AS year_actual,
EXTRACT(MONTH FROM d.date_actual)::INTEGER AS month_actual,
EXTRACT(DAY FROM d.date_actual)::INTEGER AS day_actual,
EXTRACT(ISODOW FROM d.date_actual)::INTEGER AS day_of_week,
TO_CHAR(d.date_actual, 'FMDay') AS day_name,
EXTRACT(ISODOW FROM d.date_actual) IN (6, 7) AS is_weekend
FROM (
SELECT generate_series('2016-01-01'::DATE, '2030-12-31'::DATE, '1 day'::INTERVAL)::DATE
AS date_actual
) AS d
WHERE NOT EXISTS (SELECT 1 FROM dds.dim_calendar LIMIT 1);
ANALYZE dds.dim_calendar;
```
### 5.2. dim_airports, dim_airplanes, dim_tariffs, dim_passengers — SCD1 UPSERT
> Все SCD1-измерения используют один и тот же паттерн: UPDATE существующих + INSERT новых
> с `MAX(sk) + ROW_NUMBER()` для стабильных суррогатных ключей.
Паттерн (на примере airports):
```sql
-- Statement 1: UPDATE существующих записей (если атрибуты изменились)
UPDATE dds.dim_airports AS d
SET airport_name = s.airport_name,
city = s.city,
country = s.country,
timezone = s.timezone,
coordinates = s.coordinates,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM ods.airports AS s
WHERE d.airport_bk = s.airport_code
AND (d.airport_name IS DISTINCT FROM s.airport_name
OR d.city IS DISTINCT FROM s.city
OR d.country IS DISTINCT FROM s.country
OR d.timezone IS DISTINCT FROM s.timezone
OR d.coordinates IS DISTINCT FROM s.coordinates);
-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER())
WITH max_sk AS (
SELECT COALESCE(MAX(airport_sk), 0) AS v FROM dds.dim_airports
)
INSERT INTO dds.dim_airports (
airport_sk, airport_bk, airport_name, city, country,
timezone, coordinates, created_at, updated_at, _load_id, _load_ts
)
SELECT
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.airport_code)::INTEGER,
s.airport_code, s.airport_name, s.city, s.country,
s.timezone, s.coordinates,
now(), now(), '{{ run_id }}', now()
FROM ods.airports AS s
WHERE NOT EXISTS (
SELECT 1 FROM dds.dim_airports d WHERE d.airport_bk = s.airport_code
);
ANALYZE dds.dim_airports;
```
**dim_airplanes** — аналогично, но с LEFT JOIN на `(SELECT airplane_code, COUNT(*) AS total_seats FROM ods.seats GROUP BY 1)` для обогащения `total_seats`.
**dim_tariffs** — аналогично, но источник: `SELECT DISTINCT fare_conditions FROM ods.segments WHERE fare_conditions IS NOT NULL AND fare_conditions <> ''`.
**dim_passengers** — аналогично, но с дедупликацией: `ROW_NUMBER() OVER (PARTITION BY passenger_id ORDER BY event_ts DESC NULLS LAST, _load_ts DESC, ticket_no DESC)`, берём `rn = 1`. Третий ключ `ticket_no DESC` — стабильный tie-breaker при одинаковых timestamp.
### 5.3. dim_routes — SCD2 с hashdiff
```sql
-- CTE: текущее состояние маршрутов из ODS (последняя версия по validity).
-- В учебных целях используем классический SCD2 с hashdiff для демонстрации
-- паттерна. Хотя у routes в источнике есть поле validity, мы не опираемся
-- на него для версионирования — DWH сам детектит изменения атрибутов через хэш.
-- Statement 1: Закрыть устаревшие версии (valid_to = текущая дата)
WITH src AS (
SELECT
route_no,
departure_airport,
arrival_airport,
airplane_code,
days_of_week,
departure_time,
duration,
md5(
COALESCE(departure_airport, '') || '|' ||
COALESCE(arrival_airport, '') || '|' ||
COALESCE(airplane_code, '') || '|' ||
COALESCE(days_of_week, '') || '|' ||
COALESCE(departure_time::TEXT, '') || '|' ||
COALESCE(duration::TEXT, '')
) AS hashdiff,
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
FROM ods.routes
)
UPDATE dds.dim_routes AS d
SET valid_to = CURRENT_DATE,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM src AS s
WHERE s.rn = 1
AND d.route_bk = s.route_no
AND d.valid_to IS NULL -- только текущая версия
AND d.hashdiff <> s.hashdiff; -- атрибуты изменились
-- Statement 2: Вставить новые версии (для изменённых и совсем новых route_no)
WITH src AS (
SELECT
route_no,
departure_airport,
arrival_airport,
airplane_code,
days_of_week,
departure_time,
duration,
md5(
COALESCE(departure_airport, '') || '|' ||
COALESCE(arrival_airport, '') || '|' ||
COALESCE(airplane_code, '') || '|' ||
COALESCE(days_of_week, '') || '|' ||
COALESCE(departure_time::TEXT, '') || '|' ||
COALESCE(duration::TEXT, '')
) AS hashdiff,
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
FROM ods.routes
),
max_sk AS (
SELECT COALESCE(MAX(route_sk), 0) AS v FROM dds.dim_routes
)
INSERT INTO dds.dim_routes (
route_sk, route_bk, departure_airport, arrival_airport, airplane_code,
days_of_week, departure_time, duration,
hashdiff, valid_from, valid_to, created_at, updated_at, _load_id, _load_ts
)
SELECT
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.route_no)::INTEGER,
s.route_no,
s.departure_airport,
s.arrival_airport,
s.airplane_code,
s.days_of_week,
s.departure_time,
s.duration,
s.hashdiff,
CURRENT_DATE, -- valid_from = сегодня
NULL, -- valid_to = NULL (текущая версия)
now(), now(), '{{ run_id }}', now()
FROM src AS s
WHERE s.rn = 1
AND NOT EXISTS (
SELECT 1 FROM dds.dim_routes d
WHERE d.route_bk = s.route_no
AND d.valid_to IS NULL
AND d.hashdiff = s.hashdiff
);
ANALYZE dds.dim_routes;
```
### 5.4. fact_flight_sales — инкрементальный UPSERT
```sql
-- Statement 1: UPDATE существующих строк факта.
-- ВАЖНО: обновляем ТОЛЬКО мутабельные поля (is_boarded, seat_no, price).
-- Dimension SK (route_sk, airport_sk, airplane_sk и т.д.) НЕ перезаписываем —
-- они зафиксированы на момент INSERT и отражают историческое состояние.
UPDATE dds.fact_flight_sales AS f
SET seat_no = bp.seat_no,
price = seg.segment_amount,
is_boarded = (bp.ticket_no IS NOT NULL),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM ods.segments AS seg
LEFT JOIN ods.boarding_passes AS bp
ON bp.ticket_no = seg.ticket_no AND bp.flight_id = seg.flight_id
WHERE f.ticket_no = seg.ticket_no
AND f.flight_id = seg.flight_id
AND (f.is_boarded IS DISTINCT FROM (bp.ticket_no IS NOT NULL)
OR f.price IS DISTINCT FROM seg.segment_amount
OR f.seat_no IS DISTINCT FROM bp.seat_no);
-- Statement 2: INSERT новых строк факта.
-- Dimension SK фиксируются на момент вставки (point-in-time для SCD2 routes).
WITH fact_src AS (
SELECT
seg.ticket_no,
seg.flight_id,
cal.calendar_sk,
dep.airport_sk AS departure_airport_sk,
arr.airport_sk AS arrival_airport_sk,
ap.airplane_sk,
tar.tariff_sk,
pax.passenger_sk,
rte.route_sk,
tkt.book_ref,
bkg.book_date::DATE AS book_date,
bp.seat_no,
seg.segment_amount AS price,
(bp.ticket_no IS NOT NULL) AS is_boarded
FROM ods.segments AS seg
JOIN ods.tickets AS tkt ON tkt.ticket_no = seg.ticket_no
JOIN ods.bookings AS bkg ON bkg.book_ref = tkt.book_ref
JOIN ods.flights AS flt ON flt.flight_id = seg.flight_id
-- SCD2 point-in-time: версия маршрута, актуальная на дату вылета
LEFT JOIN dds.dim_routes AS rte
ON rte.route_bk = flt.route_no
AND flt.scheduled_departure::DATE >= rte.valid_from
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
LEFT JOIN dds.dim_calendar AS cal ON cal.date_actual = flt.scheduled_departure::DATE
LEFT JOIN dds.dim_airports AS dep ON dep.airport_bk = rte.departure_airport
LEFT JOIN dds.dim_airports AS arr ON arr.airport_bk = rte.arrival_airport
LEFT JOIN dds.dim_airplanes AS ap ON ap.airplane_bk = rte.airplane_code
LEFT JOIN dds.dim_tariffs AS tar ON tar.fare_conditions = seg.fare_conditions
LEFT JOIN dds.dim_passengers AS pax ON pax.passenger_bk = tkt.passenger_id
LEFT JOIN ods.boarding_passes AS bp
ON bp.ticket_no = seg.ticket_no AND bp.flight_id = seg.flight_id
)
INSERT INTO dds.fact_flight_sales (
calendar_sk, departure_airport_sk, arrival_airport_sk, airplane_sk,
tariff_sk, passenger_sk, route_sk,
book_ref, ticket_no, flight_id, book_date, seat_no,
price, is_boarded, _load_id, _load_ts
)
SELECT
s.calendar_sk, s.departure_airport_sk, s.arrival_airport_sk, s.airplane_sk,
s.tariff_sk, s.passenger_sk, s.route_sk,
s.book_ref, s.ticket_no, s.flight_id, s.book_date, s.seat_no,
s.price, s.is_boarded,
'{{ run_id }}', now()
FROM fact_src AS s
WHERE NOT EXISTS (
SELECT 1 FROM dds.fact_flight_sales f
WHERE f.ticket_no = s.ticket_no AND f.flight_id = s.flight_id
);
ANALYZE dds.fact_flight_sales;
```
### 5.5. Модель историчности факта
Dimension SK фиксируются **при INSERT** и не перезаписываются:
- `route_sk` — версия маршрута на дату `scheduled_departure` (point-in-time SCD2 lookup);
- `departure_airport_sk`, `arrival_airport_sk`, `airplane_sk` — из той же версии маршрута;
- `calendar_sk`, `tariff_sk`, `passenger_sk` — из текущих SCD1-измерений на момент INSERT.
UPDATE факта обновляет только **мутабельные поля**: `is_boarded`, `seat_no`, `price`
(появился посадочный, изменилась цена). Это гарантирует, что аналитика по историческим
периодам использует правильные версии измерений.
### 5.6. Политика backfill/reprocess
- **Повторный запуск** с теми же данными ODS — безопасен (идемпотентно).
- **Повторный запуск после изменения маршрутов в ODS**: dim_routes создаст новую SCD2-версию;
уже вставленные строки факта сохранят старый `route_sk` (историчность).
Новые строки факта получат актуальный `route_sk` через point-in-time lookup.
- **Полная пересборка факта**: если нужна — `TRUNCATE dds.fact_flight_sales` и повторный
запуск DAG. Все SK будут пересчитаны через point-in-time lookup.
### 5.7. Идемпотентность паттернов
- **dim_calendar**: `WHERE NOT EXISTS` — повторный запуск не создаёт дублей.
- **SCD1 измерения**: `UPDATE + INSERT WHERE NOT EXISTS` — натурально идемпотентно (как в ODS).
- **SCD2 dim_routes**: `UPDATE WHERE hashdiff <>` + `INSERT WHERE NOT EXISTS (bk + valid_to IS NULL + hashdiff =)` — повторный запуск с теми же данными ODS не создаёт дублей и не закрывает версии повторно.
- **fact_flight_sales**: `UPDATE + INSERT WHERE NOT EXISTS` — идемпотентно по зерну.
---
## 6) DQ-проверки
Каждый DQ-скрипт: PL/pgSQL `DO $$` блок, `RAISE EXCEPTION` при нарушении (как в ODS).
### 6.1. Обязательные проверки по типам
**Все измерения (кроме calendar):**
1. Таблица не пуста
2. Нет дублей по `_sk`
3. Нет дублей по `_bk` (для SCD1; для SCD2 — нет дублей по `_bk` WHERE `valid_to IS NULL`)
4. Покрытие ODS: все ключи из ODS присутствуют в DDS
5. Обязательные поля не NULL/пустые
**dim_calendar:**
1. Не менее 1000 строк
2. Нет дублей по `calendar_sk` и `date_actual`
3. Обязательные поля не NULL
**dim_routes (SCD2 специфика):**
1. Не более одной текущей версии на `route_bk` (`WHERE valid_to IS NULL` — уникальность)
2. `hashdiff` не NULL/пустой
3. `valid_from` не NULL
4. Корректность интервалов: `valid_from < valid_to` для всех закрытых версий
5. Нет перекрытий версий: для одного `route_bk` интервалы `[valid_from, valid_to)` не пересекаются
6. Покрытие: все `route_no` из ODS имеют хотя бы одну версию в DDS
**fact_flight_sales:**
1. Таблица не пуста
2. Нет дублей по зерну `(ticket_no, flight_id)`
3. Количество строк = `COUNT(*)` из `ods.segments`
4. **Обязательные FK**: `passenger_sk IS NULL` = 0, `tariff_sk IS NULL` = 0
5. **FK маршрута**: `route_sk IS NULL` — допустимо при аномалиях, считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк
6. **Calendar**: `calendar_sk IS NULL` — допустимо если `scheduled_departure IS NULL` в ODS; считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк
7. Обязательные поля: `book_ref`, `ticket_no`, `flight_id`, `is_boarded` не NULL
### 6.2. Пример DQ для dim_routes (SCD2)
```sql
DO $$
DECLARE
v_row_count BIGINT;
v_dup_sk BIGINT;
v_dup_current BIGINT;
v_missing_count BIGINT;
v_null_count BIGINT;
BEGIN
-- Таблица не пуста
SELECT COUNT(*) INTO v_row_count FROM dds.dim_routes;
IF v_row_count = 0 THEN
RAISE EXCEPTION 'DQ FAILED: dds.dim_routes пуста.';
END IF;
-- Нет дублей по SK
SELECT COUNT(*) - COUNT(DISTINCT route_sk) INTO v_dup_sk FROM dds.dim_routes;
IF v_dup_sk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены дубликаты route_sk: %', v_dup_sk;
END IF;
-- SCD2: корректность интервалов (valid_from < valid_to для закрытых версий)
SELECT COUNT(*) INTO v_null_count
FROM dds.dim_routes
WHERE valid_to IS NOT NULL AND valid_from >= valid_to;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены версии с valid_from >= valid_to: %',
v_null_count;
END IF;
-- SCD2: не более одной текущей версии на route_bk
SELECT COUNT(*) INTO v_dup_current
FROM (
SELECT route_bk
FROM dds.dim_routes
WHERE valid_to IS NULL
GROUP BY route_bk
HAVING COUNT(*) > 1
) AS d;
IF v_dup_current <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены route_bk с > 1 текущей версией: %',
v_dup_current;
END IF;
-- Покрытие ODS (все route_no имеют хотя бы одну версию)
SELECT COUNT(*) INTO v_missing_count
FROM (SELECT DISTINCT route_no FROM ods.routes) AS o
WHERE NOT EXISTS (
SELECT 1 FROM dds.dim_routes d WHERE d.route_bk = o.route_no
);
IF v_missing_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes отсутствуют маршруты из ODS: %', v_missing_count;
END IF;
-- Обязательные поля
SELECT COUNT(*) INTO v_null_count
FROM dds.dim_routes
WHERE route_sk IS NULL
OR route_bk IS NULL OR route_bk = ''
OR departure_airport IS NULL OR departure_airport = ''
OR arrival_airport IS NULL OR arrival_airport = ''
OR airplane_code IS NULL OR airplane_code = ''
OR hashdiff IS NULL OR hashdiff = ''
OR valid_from IS NULL
OR created_at IS NULL
OR updated_at IS NULL
OR _load_id IS NULL OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены NULL обязательные поля: %', v_null_count;
END IF;
RAISE NOTICE 'DQ PASSED: dds.dim_routes ок, строк=% (версий)', v_row_count;
END $$;
```
### 6.3. Пример DQ для fact_flight_sales
```sql
DO $$
DECLARE
v_row_count BIGINT;
v_ods_count BIGINT;
v_dup_count BIGINT;
v_null_passenger BIGINT;
v_null_tariff BIGINT;
v_null_required BIGINT;
BEGIN
-- Таблица не пуста
SELECT COUNT(*) INTO v_row_count FROM dds.fact_flight_sales;
IF v_row_count = 0 THEN
RAISE EXCEPTION 'DQ FAILED: dds.fact_flight_sales пуста.';
END IF;
-- Покрытие: количество строк = ods.segments
SELECT COUNT(*) INTO v_ods_count FROM ods.segments;
IF v_row_count <> v_ods_count THEN
RAISE EXCEPTION
'DQ FAILED: dds.fact_flight_sales (%) <> ods.segments (%). Потеряны строки.',
v_row_count, v_ods_count;
END IF;
-- Нет дублей по зерну
SELECT COUNT(*) INTO v_dup_count
FROM (
SELECT ticket_no, flight_id
FROM dds.fact_flight_sales
GROUP BY ticket_no, flight_id
HAVING COUNT(*) > 1
) AS d;
IF v_dup_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.fact_flight_sales дубликаты (ticket_no, flight_id): %',
v_dup_count;
END IF;
-- Ссылочная целостность: passenger_sk
SELECT COUNT(*) INTO v_null_passenger
FROM dds.fact_flight_sales WHERE passenger_sk IS NULL;
IF v_null_passenger <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales строки без passenger_sk: %', v_null_passenger;
END IF;
-- Ссылочная целостность: tariff_sk
SELECT COUNT(*) INTO v_null_tariff
FROM dds.fact_flight_sales WHERE tariff_sk IS NULL;
IF v_null_tariff <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales строки без tariff_sk: %', v_null_tariff;
END IF;
-- FK маршрута: route_sk (допустимо при аномалиях, фейлим если > 1%)
DECLARE v_null_route BIGINT;
SELECT COUNT(*) INTO v_null_route
FROM dds.fact_flight_sales WHERE route_sk IS NULL;
IF v_null_route > 0 THEN
IF v_null_route * 100 / v_row_count > 1 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales слишком много строк без route_sk: % (>1%%)',
v_null_route;
ELSE
RAISE NOTICE
'DQ WARNING: в fact_flight_sales строк без route_sk: % (<=1%%, допустимо)',
v_null_route;
END IF;
END IF;
-- Calendar: calendar_sk (допустимо если scheduled_departure IS NULL)
DECLARE v_null_calendar BIGINT;
SELECT COUNT(*) INTO v_null_calendar
FROM dds.fact_flight_sales WHERE calendar_sk IS NULL;
IF v_null_calendar > 0 THEN
IF v_null_calendar * 100 / v_row_count > 1 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales слишком много строк без calendar_sk: % (>1%%)',
v_null_calendar;
ELSE
RAISE NOTICE
'DQ WARNING: в fact_flight_sales строк без calendar_sk: % (<=1%%, допустимо)',
v_null_calendar;
END IF;
END IF;
-- Обязательные поля
SELECT COUNT(*) INTO v_null_required
FROM dds.fact_flight_sales
WHERE book_ref IS NULL OR book_ref = ''
OR ticket_no IS NULL OR ticket_no = ''
OR flight_id IS NULL
OR is_boarded IS NULL
OR _load_id IS NULL OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_required <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales NULL обязательные поля: %', v_null_required;
END IF;
RAISE NOTICE 'DQ PASSED: dds.fact_flight_sales ок, строк=%', v_row_count;
END $$;
```
---
## 7) Структура файлов
```text
sql/dds/ (21 SQL-файл)
├── dim_calendar_ddl.sql
├── dim_calendar_load.sql
├── dim_calendar_dq.sql
├── dim_airports_ddl.sql
├── dim_airports_load.sql
├── dim_airports_dq.sql
├── dim_airplanes_ddl.sql
├── dim_airplanes_load.sql
├── dim_airplanes_dq.sql
├── dim_tariffs_ddl.sql
├── dim_tariffs_load.sql
├── dim_tariffs_dq.sql
├── dim_passengers_ddl.sql
├── dim_passengers_load.sql
├── dim_passengers_dq.sql
├── dim_routes_ddl.sql
├── dim_routes_load.sql
├── dim_routes_dq.sql
├── fact_flight_sales_ddl.sql
├── fact_flight_sales_load.sql
└── fact_flight_sales_dq.sql
airflow/dags/ (2 новых DAG)
├── bookings_dds_ddl.py
└── bookings_to_gp_dds.py
sql/ddl_gp.sql (+ \i dds/*_ddl.sql в конец)
tests/test_dags_smoke.py (+ 2 smoke-теста)
docs/bookings_to_gp_dds.md (документация для студентов)
docs/internal/bookings_dds_design.md (этот план)
docs/internal/db_schema.md (обновить: добавить dim_routes, статус DDS)
```
---
## 8) DAG `bookings_dds_ddl`
По аналогии с `bookings_ods_ddl.py` (`airflow/dags/bookings_ods_ddl.py`).
**Ключевые параметры:**
- `dag_id = "bookings_dds_ddl"`
- `schedule = None`
- `template_searchpath = "/sql"`
- `tags = ["demo", "greenplum", "ddl", "bookings", "dds"]`
- `description = "Учебный DDL DAG: создаёт/обновляет dds.* для bookings"`
**Задачи (линейная цепочка из 7 задач):**
1. `apply_dds_dim_calendar_ddl``dds/dim_calendar_ddl.sql`
2. `apply_dds_dim_airports_ddl``dds/dim_airports_ddl.sql`
3. `apply_dds_dim_airplanes_ddl``dds/dim_airplanes_ddl.sql`
4. `apply_dds_dim_tariffs_ddl``dds/dim_tariffs_ddl.sql`
5. `apply_dds_dim_passengers_ddl``dds/dim_passengers_ddl.sql`
6. `apply_dds_dim_routes_ddl``dds/dim_routes_ddl.sql`
7. `apply_dds_fact_flight_sales_ddl``dds/fact_flight_sales_ddl.sql`
---
## 9) DAG `bookings_to_gp_dds`: граф зависимостей
По аналогии с `bookings_to_gp_ods.py` (`airflow/dags/bookings_to_gp_ods.py`).
**Ключевые параметры:**
- `dag_id = "bookings_to_gp_dds"`
- `schedule = None`, `max_active_runs = 1`
- `_load_id = {{ run_id }}` (не нужен `resolve_stg_batch_id`)
### 9.1. Граф
```text
load_dds_dim_calendar -> dq_dds_dim_calendar
|
v (после calendar — параллельно 5 измерений)
load_dds_dim_airports -> dq_dds_dim_airports
load_dds_dim_airplanes -> dq_dds_dim_airplanes
load_dds_dim_tariffs -> dq_dds_dim_tariffs
load_dds_dim_passengers -> dq_dds_dim_passengers
load_dds_dim_routes -> dq_dds_dim_routes
|
v (факт после ВСЕХ 6 измерений)
load_dds_fact_flight_sales -> dq_dds_fact_flight_sales -> finish_dds_summary
```
Задач: 7 load + 7 dq + 1 finish = **15 задач**.
### 9.2. Зависимости (Python)
```python
load_dds_dim_calendar >> dq_dds_dim_calendar
# 5 измерений параллельно после calendar
dq_dds_dim_calendar >> [
load_dds_dim_airports, load_dds_dim_airplanes,
load_dds_dim_tariffs, load_dds_dim_passengers,
load_dds_dim_routes
]
load_dds_dim_airports >> dq_dds_dim_airports
load_dds_dim_airplanes >> dq_dds_dim_airplanes
load_dds_dim_tariffs >> dq_dds_dim_tariffs
load_dds_dim_passengers >> dq_dds_dim_passengers
load_dds_dim_routes >> dq_dds_dim_routes
# Факт после всех измерений
[dq_dds_dim_airports, dq_dds_dim_airplanes,
dq_dds_dim_tariffs, dq_dds_dim_passengers,
dq_dds_dim_routes] >> load_dds_fact_flight_sales
load_dds_fact_flight_sales >> dq_dds_fact_flight_sales >> finish_dds_summary
```
### 9.3. Почему calendar первая
Факт ссылается на `calendar_sk`. Calendar — статическая таблица, заполняется один раз.
Но если DDS запускается впервые, calendar должна быть заполнена до загрузки факта.
Остальные 5 измерений не зависят друг от друга в DDS (FK-зависимости уже проверены в ODS).
---
## 10) Smoke-тесты
Добавить в `tests/test_dags_smoke.py` два теста:
### test_bookings_dds_ddl_dag_structure
- 7 задач: `apply_dds_dim_{calendar,airports,airplanes,tariffs,passengers,routes}_ddl`, `apply_dds_fact_flight_sales_ddl`
- Линейная цепочка: каждая задача reachable от предыдущей
### test_bookings_to_gp_dds_dag_structure
- 15 задач (7 load + 7 dq + `finish_dds_summary`)
- `load → dq` для каждого объекта (direct edge)
- `dq_dds_dim_calendar` → все 5 остальных load-измерений
- airports и airplanes не зависят друг от друга (параллельность)
- факт reachable от всех 6 dq измерений (через `[...] >> load_dds_fact`)
- `finish_dds_summary` reachable от `dq_dds_fact_flight_sales`
---
## 11) Порядок реализации
1. DDL: 7 файлов `sql/dds/*_ddl.sql` (calendar, airports, airplanes, tariffs, passengers, routes, fact)
2. Подключить DDL в `sql/ddl_gp.sql` (добавить `\i dds/*_ddl.sql`)
3. DAG `airflow/dags/bookings_dds_ddl.py`
4. Load SQL: 7 файлов `sql/dds/*_load.sql`
5. DQ SQL: 7 файлов `sql/dds/*_dq.sql`
6. DAG `airflow/dags/bookings_to_gp_dds.py`
7. Smoke-тесты в `tests/test_dags_smoke.py` (+2 теста)
8. Документация `docs/bookings_to_gp_dds.md`
9. Обновить `docs/internal/db_schema.md` — отразить `dim_routes` и актуальный статус DDS
Итого: **21 SQL-файл** + **2 DAG** + **обновления 3 существующих файлов** + **1 новый doc-файл**.
---
## 12) Критические файлы-образцы (patterns to follow)
| Что реализуем | Образец в репозитории |
|---------------|---------|
| DDS DDL DAG | `airflow/dags/bookings_ods_ddl.py` |
| DDS ETL DAG | `airflow/dags/bookings_to_gp_ods.py` |
| DDL SQL | `sql/ods/airports_ddl.sql` |
| SCD1 UPSERT SQL | `sql/ods/airports_load.sql`, `sql/ods/bookings_load.sql` |
| DQ SQL (PL/pgSQL) | `sql/ods/airports_dq.sql`, `sql/ods/segments_dq.sql` |
| Smoke-тесты | `tests/test_dags_smoke.py` (тесты ODS DAG) |
| Подключение DDL | `sql/ddl_gp.sql` (секция ODS `\i` директивы) |
---
## 13) Критерии готовности (Definition of Done)
1. Оба новых DAG парсятся и проходят smoke-тесты (`make test`)
2. `make ddl-gp` создаёт STG+ODS+DDS без ошибок
3. DAG `bookings_to_gp_dds` завершается успешно после ODS
4. Все DQ-задачи зелёные
5. В DDS нет дублей по SK и BK
6. `fact_flight_sales` содержит столько строк, сколько в `ods.segments`
7. Нейминг консистентен: `_bk`, `_sk`, `valid_from`/`valid_to`, `hashdiff`, `_load_id`, `_load_ts`, `created_at`/`updated_at`
8. `make fmt` / `make lint` проходят
9. `dim_routes` демонстрирует SCD2 с реальными версиями
---
## 14) Как проверять вручную
```bash
make up
make ddl-gp # создать STG+ODS+DDS-объекты
# Trigger bookings_to_gp_stage (загрузить STG)
# Trigger bookings_to_gp_ods (загрузить ODS)
# Trigger bookings_to_gp_dds (загрузить DDS)
make gp-psql
```
Проверочные SQL:
```sql
-- 1) Количество строк в измерениях и факте
SELECT 'dim_calendar' AS tbl, COUNT(*) FROM dds.dim_calendar
UNION ALL
SELECT 'dim_airports', COUNT(*) FROM dds.dim_airports
UNION ALL
SELECT 'dim_airplanes', COUNT(*) FROM dds.dim_airplanes
UNION ALL
SELECT 'dim_tariffs', COUNT(*) FROM dds.dim_tariffs
UNION ALL
SELECT 'dim_passengers', COUNT(*) FROM dds.dim_passengers
UNION ALL
SELECT 'dim_routes', COUNT(*) FROM dds.dim_routes
UNION ALL
SELECT 'fact_flight_sales', COUNT(*) FROM dds.fact_flight_sales;
-- 2) Покрытие факта: должно совпадать с ods.segments
SELECT
(SELECT COUNT(*) FROM dds.fact_flight_sales) AS fact_rows,
(SELECT COUNT(*) FROM ods.segments) AS ods_rows;
-- 3) SCD2 dim_routes: версии маршрутов
SELECT route_bk, COUNT(*) AS versions
FROM dds.dim_routes
GROUP BY route_bk
HAVING COUNT(*) > 1
ORDER BY versions DESC;
-- 4) NULL суррогатные ключи в факте (потенциальные аномалии)
SELECT
SUM(CASE WHEN calendar_sk IS NULL THEN 1 ELSE 0 END) AS null_calendar,
SUM(CASE WHEN departure_airport_sk IS NULL THEN 1 ELSE 0 END) AS null_dep_airport,
SUM(CASE WHEN arrival_airport_sk IS NULL THEN 1 ELSE 0 END) AS null_arr_airport,
SUM(CASE WHEN airplane_sk IS NULL THEN 1 ELSE 0 END) AS null_airplane,
SUM(CASE WHEN tariff_sk IS NULL THEN 1 ELSE 0 END) AS null_tariff,
SUM(CASE WHEN passenger_sk IS NULL THEN 1 ELSE 0 END) AS null_passenger,
SUM(CASE WHEN route_sk IS NULL THEN 1 ELSE 0 END) AS null_route
FROM dds.fact_flight_sales;
-- 5) Пример аналитического запроса: выручка по тарифам
SELECT
t.fare_conditions,
COUNT(*) AS segments,
SUM(f.price) AS total_revenue,
AVG(f.price) AS avg_price
FROM dds.fact_flight_sales AS f
JOIN dds.dim_tariffs AS t ON t.tariff_sk = f.tariff_sk
GROUP BY t.fare_conditions
ORDER BY total_revenue DESC;
-- 6) Пример запроса с SCD2: маршруты и их версии
SELECT
r.route_bk,
r.departure_airport,
r.arrival_airport,
r.airplane_code,
r.valid_from,
r.valid_to,
COUNT(f.ticket_no) AS fact_rows
FROM dds.dim_routes AS r
LEFT JOIN dds.fact_flight_sales AS f ON f.route_sk = r.route_sk
GROUP BY 1, 2, 3, 4, 5, 6
ORDER BY r.route_bk, r.valid_from;
```
---
## 15) Что будет следующим шагом
- Data Mart (витрина) поверх DDS
- Point-in-time lookup для `dim_routes` в факте (`BETWEEN valid_from AND valid_to`)
- `dim_calendar.is_holiday` (если появится источник)