fix(dwh): доработан DDS-план после повторного ревью

- Зачем:
  - закрыть оставшиеся риски и противоречия в проектировании DDS перед реализацией.
- Что:
  - добавлена обработка исчезнувших маршрутов в SCD2 (`UPDATE missing`) и уточнён паттерн идемпотентности.
  - уточнены правила SCD2 на DATE-гранулярности (`valid_from <= valid_to`) и добавлена DQ-проверка current-среза DDS vs ODS.
  - расширены DQ-требования (покрытие календаря по датам flights, route-related FK), исправлены критерии готовности и текстовые несоответствия.
- Проверка:
  - git diff -- docs/internal/bookings_dds_design.md
This commit is contained in:
2026-02-24 00:03:37 +03:00
parent 06a5b7bc4c
commit e98821f28d
+98 -22
View File
@@ -328,6 +328,27 @@ WHERE s.rn = 1
AND d.valid_to IS NULL -- только текущая версия AND d.valid_to IS NULL -- только текущая версия
AND d.hashdiff <> s.hashdiff; -- атрибуты изменились AND d.hashdiff <> s.hashdiff; -- атрибуты изменились
-- Statement 1.1: Закрыть "исчезнувшие" маршруты
-- (есть в текущем срезе DDS, но отсутствуют в текущем состоянии ODS).
WITH src AS (
SELECT
route_no,
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
FROM ods.routes
)
UPDATE dds.dim_routes AS d
SET valid_to = CURRENT_DATE,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
WHERE d.valid_to IS NULL
AND NOT EXISTS (
SELECT 1
FROM src AS s
WHERE s.rn = 1
AND s.route_no = d.route_bk
);
-- Statement 2: Вставить новые версии (для изменённых и совсем новых route_no) -- Statement 2: Вставить новые версии (для изменённых и совсем новых route_no)
WITH src AS ( WITH src AS (
SELECT SELECT
@@ -367,7 +388,15 @@ SELECT
s.departure_time, s.departure_time,
s.duration, s.duration,
s.hashdiff, s.hashdiff,
CURRENT_DATE, -- valid_from = сегодня -- valid_from: для совсем новых route_no — sentinel '1900-01-01'
-- (чтобы point-in-time lookup покрыл все исторические рейсы);
-- для обновлённых (уже были в DDS, но hashdiff изменился) — CURRENT_DATE.
CASE
WHEN EXISTS (
SELECT 1 FROM dds.dim_routes d2 WHERE d2.route_bk = s.route_no
) THEN CURRENT_DATE
ELSE '1900-01-01'::DATE
END AS valid_from,
NULL, -- valid_to = NULL (текущая версия) NULL, -- valid_to = NULL (текущая версия)
now(), now(), '{{ run_id }}', now() now(), now(), '{{ run_id }}', now()
FROM src AS s FROM src AS s
@@ -382,6 +411,10 @@ WHERE s.rn = 1
ANALYZE dds.dim_routes; ANALYZE dds.dim_routes;
``` ```
Примечание: `valid_from`/`valid_to` имеют дневную гранулярность (`DATE`).
Если маршрут меняется несколько раз в один день, допускается закрытая версия с
`valid_from = valid_to` (нулевой интервал), чтобы не терять факт изменения.
### 5.4. fact_flight_sales — инкрементальный UPSERT ### 5.4. fact_flight_sales — инкрементальный UPSERT
```sql ```sql
@@ -485,7 +518,7 @@ UPDATE факта обновляет только **мутабельные по
- **dim_calendar**: `WHERE NOT EXISTS` — повторный запуск не создаёт дублей. - **dim_calendar**: `WHERE NOT EXISTS` — повторный запуск не создаёт дублей.
- **SCD1 измерения**: `UPDATE + INSERT WHERE NOT EXISTS` — натурально идемпотентно (как в ODS). - **SCD1 измерения**: `UPDATE + INSERT WHERE NOT EXISTS` — натурально идемпотентно (как в ODS).
- **SCD2 dim_routes**: `UPDATE WHERE hashdiff <>` + `INSERT WHERE NOT EXISTS (bk + valid_to IS NULL + hashdiff =)` — повторный запуск с теми же данными ODS не создаёт дублей и не закрывает версии повторно. - **SCD2 dim_routes**: `UPDATE changed` + `UPDATE missing` + `INSERT WHERE NOT EXISTS (bk + valid_to IS NULL + hashdiff =)` — повторный запуск с теми же данными ODS не создаёт дублей и не закрывает версии повторно.
- **fact_flight_sales**: `UPDATE + INSERT WHERE NOT EXISTS` — идемпотентно по зерну. - **fact_flight_sales**: `UPDATE + INSERT WHERE NOT EXISTS` — идемпотентно по зерну.
--- ---
@@ -507,21 +540,23 @@ UPDATE факта обновляет только **мутабельные по
1. Не менее 1000 строк 1. Не менее 1000 строк
2. Нет дублей по `calendar_sk` и `date_actual` 2. Нет дублей по `calendar_sk` и `date_actual`
3. Обязательные поля не NULL 3. Обязательные поля не NULL
4. Покрывает диапазон дат из `ods.flights.scheduled_departure` (для NOT NULL)
**dim_routes (SCD2 специфика):** **dim_routes (SCD2 специфика):**
1. Не более одной текущей версии на `route_bk` (`WHERE valid_to IS NULL` — уникальность) 1. Не более одной текущей версии на `route_bk` (`WHERE valid_to IS NULL` — уникальность)
2. `hashdiff` не NULL/пустой 2. `hashdiff` не NULL/пустой
3. `valid_from` не NULL 3. `valid_from` не NULL
4. Корректность интервалов: `valid_from < valid_to` для всех закрытых версий 4. Корректность интервалов: `valid_from <= valid_to` для всех закрытых версий (DATE-гранулярность)
5. Нет перекрытий версий: для одного `route_bk` интервалы `[valid_from, valid_to)` не пересекаются 5. Нет перекрытий версий: для одного `route_bk` интервалы `[valid_from, valid_to)` не пересекаются
6. Покрытие: все `route_no` из ODS имеют хотя бы одну версию в DDS 6. Покрытие: все `route_no` из ODS имеют хотя бы одну версию в DDS
7. Текущий срез DDS консистентен с ODS: `route_bk` с `valid_to IS NULL` есть в `ods.routes`
**fact_flight_sales:** **fact_flight_sales:**
1. Таблица не пуста 1. Таблица не пуста
2. Нет дублей по зерну `(ticket_no, flight_id)` 2. Нет дублей по зерну `(ticket_no, flight_id)`
3. Количество строк = `COUNT(*)` из `ods.segments` 3. Количество строк = `COUNT(*)` из `ods.segments`
4. **Обязательные FK**: `passenger_sk IS NULL` = 0, `tariff_sk IS NULL` = 0 4. **Обязательные FK**: `passenger_sk IS NULL` = 0, `tariff_sk IS NULL` = 0
5. **FK маршрута**: `route_sk IS NULL` — допустимо при аномалиях, считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк 5. **FK маршрута**: NULL в любом из `route_sk`, `departure_airport_sk`, `arrival_airport_sk`, `airplane_sk` — допустимо при аномалиях, считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк
6. **Calendar**: `calendar_sk IS NULL` — допустимо если `scheduled_departure IS NULL` в ODS; считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк 6. **Calendar**: `calendar_sk IS NULL` — допустимо если `scheduled_departure IS NULL` в ODS; считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк
7. Обязательные поля: `book_ref`, `ticket_no`, `flight_id`, `is_boarded` не NULL 7. Обязательные поля: `book_ref`, `ticket_no`, `flight_id`, `is_boarded` не NULL
@@ -533,7 +568,9 @@ DECLARE
v_row_count BIGINT; v_row_count BIGINT;
v_dup_sk BIGINT; v_dup_sk BIGINT;
v_dup_current BIGINT; v_dup_current BIGINT;
v_overlap_count BIGINT;
v_missing_count BIGINT; v_missing_count BIGINT;
v_orphan_current BIGINT;
v_null_count BIGINT; v_null_count BIGINT;
BEGIN BEGIN
-- Таблица не пуста -- Таблица не пуста
@@ -549,16 +586,33 @@ BEGIN
'DQ FAILED: в dds.dim_routes найдены дубликаты route_sk: %', v_dup_sk; 'DQ FAILED: в dds.dim_routes найдены дубликаты route_sk: %', v_dup_sk;
END IF; END IF;
-- SCD2: корректность интервалов (valid_from < valid_to для закрытых версий) -- SCD2: корректность интервалов (valid_from <= valid_to для закрытых версий)
SELECT COUNT(*) INTO v_null_count SELECT COUNT(*) INTO v_null_count
FROM dds.dim_routes FROM dds.dim_routes
WHERE valid_to IS NOT NULL AND valid_from >= valid_to; WHERE valid_to IS NOT NULL AND valid_from > valid_to;
IF v_null_count <> 0 THEN IF v_null_count <> 0 THEN
RAISE EXCEPTION RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены версии с valid_from >= valid_to: %', 'DQ FAILED: в dds.dim_routes найдены версии с valid_from > valid_to: %',
v_null_count; v_null_count;
END IF; END IF;
-- SCD2: нет перекрытий интервалов для одного route_bk
SELECT COUNT(*) INTO v_overlap_count
FROM (
SELECT 1
FROM dds.dim_routes d1
JOIN dds.dim_routes d2
ON d1.route_bk = d2.route_bk
AND d1.route_sk < d2.route_sk
AND d1.valid_from < COALESCE(d2.valid_to, DATE '9999-12-31')
AND d2.valid_from < COALESCE(d1.valid_to, DATE '9999-12-31')
) AS overlaps;
IF v_overlap_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены перекрытия SCD2-интервалов: %',
v_overlap_count;
END IF;
-- SCD2: не более одной текущей версии на route_bk -- SCD2: не более одной текущей версии на route_bk
SELECT COUNT(*) INTO v_dup_current SELECT COUNT(*) INTO v_dup_current
FROM ( FROM (
@@ -585,6 +639,24 @@ BEGIN
'DQ FAILED: в dds.dim_routes отсутствуют маршруты из ODS: %', v_missing_count; 'DQ FAILED: в dds.dim_routes отсутствуют маршруты из ODS: %', v_missing_count;
END IF; END IF;
-- SCD2: current-срез DDS не содержит route_bk, которых нет в ODS
SELECT COUNT(*) INTO v_orphan_current
FROM (
SELECT DISTINCT route_bk
FROM dds.dim_routes
WHERE valid_to IS NULL
) AS d
WHERE NOT EXISTS (
SELECT 1
FROM ods.routes AS o
WHERE o.route_no = d.route_bk
);
IF v_orphan_current <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в current-срезе dds.dim_routes есть route_bk вне ODS: %',
v_orphan_current;
END IF;
-- Обязательные поля -- Обязательные поля
SELECT COUNT(*) INTO v_null_count SELECT COUNT(*) INTO v_null_count
FROM dds.dim_routes FROM dds.dim_routes
@@ -618,6 +690,8 @@ DECLARE
v_dup_count BIGINT; v_dup_count BIGINT;
v_null_passenger BIGINT; v_null_passenger BIGINT;
v_null_tariff BIGINT; v_null_tariff BIGINT;
v_null_route_related BIGINT;
v_null_calendar BIGINT;
v_null_required BIGINT; v_null_required BIGINT;
BEGIN BEGIN
-- Таблица не пуста -- Таблица не пуста
@@ -664,28 +738,30 @@ BEGIN
'DQ FAILED: в fact_flight_sales строки без tariff_sk: %', v_null_tariff; 'DQ FAILED: в fact_flight_sales строки без tariff_sk: %', v_null_tariff;
END IF; END IF;
-- FK маршрута: route_sk (допустимо при аномалиях, фейлим если > 1%) -- FK маршрута: route-related группа (допустимо при аномалиях, фейлим если > 1%)
DECLARE v_null_route BIGINT; SELECT COUNT(*) INTO v_null_route_related
SELECT COUNT(*) INTO v_null_route FROM dds.fact_flight_sales
FROM dds.fact_flight_sales WHERE route_sk IS NULL; WHERE route_sk IS NULL
IF v_null_route > 0 THEN OR departure_airport_sk IS NULL
IF v_null_route * 100 / v_row_count > 1 THEN OR arrival_airport_sk IS NULL
OR airplane_sk IS NULL;
IF v_null_route_related > 0 THEN
IF v_null_route_related * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN
RAISE EXCEPTION RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales слишком много строк без route_sk: % (>1%%)', 'DQ FAILED: в fact_flight_sales слишком много строк с NULL в route-related FK: % (>1%%)',
v_null_route; v_null_route_related;
ELSE ELSE
RAISE NOTICE RAISE NOTICE
'DQ WARNING: в fact_flight_sales строк без route_sk: % (<=1%%, допустимо)', 'DQ WARNING: в fact_flight_sales строк с NULL в route-related FK: % (<=1%%, допустимо)',
v_null_route; v_null_route_related;
END IF; END IF;
END IF; END IF;
-- Calendar: calendar_sk (допустимо если scheduled_departure IS NULL) -- Calendar: calendar_sk (допустимо если scheduled_departure IS NULL)
DECLARE v_null_calendar BIGINT;
SELECT COUNT(*) INTO v_null_calendar SELECT COUNT(*) INTO v_null_calendar
FROM dds.fact_flight_sales WHERE calendar_sk IS NULL; FROM dds.fact_flight_sales WHERE calendar_sk IS NULL;
IF v_null_calendar > 0 THEN IF v_null_calendar > 0 THEN
IF v_null_calendar * 100 / v_row_count > 1 THEN IF v_null_calendar * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN
RAISE EXCEPTION RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales слишком много строк без calendar_sk: % (>1%%)', 'DQ FAILED: в fact_flight_sales слишком много строк без calendar_sk: % (>1%%)',
v_null_calendar; v_null_calendar;
@@ -882,7 +958,7 @@ load_dds_fact_flight_sales >> dq_dds_fact_flight_sales >> finish_dds_summary
| SCD1 UPSERT SQL | `sql/ods/airports_load.sql`, `sql/ods/bookings_load.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` | | DQ SQL (PL/pgSQL) | `sql/ods/airports_dq.sql`, `sql/ods/segments_dq.sql` |
| Smoke-тесты | `tests/test_dags_smoke.py` (тесты ODS DAG) | | Smoke-тесты | `tests/test_dags_smoke.py` (тесты ODS DAG) |
| Подключение DDL | `sql/ddl_gp.sql` (секция ODS `\i` директивы) | | Подключение DDL | `sql/ddl_gp.sql` (секция DDS `\i` директивы) |
--- ---
@@ -892,7 +968,7 @@ load_dds_fact_flight_sales >> dq_dds_fact_flight_sales >> finish_dds_summary
2. `make ddl-gp` создаёт STG+ODS+DDS без ошибок 2. `make ddl-gp` создаёт STG+ODS+DDS без ошибок
3. DAG `bookings_to_gp_dds` завершается успешно после ODS 3. DAG `bookings_to_gp_dds` завершается успешно после ODS
4. Все DQ-задачи зелёные 4. Все DQ-задачи зелёные
5. В DDS нет дублей по SK и BK 5. В DDS нет дублей по SK; для SCD1 нет дублей по BK, для SCD2 не более одной current-версии BK
6. `fact_flight_sales` содержит столько строк, сколько в `ods.segments` 6. `fact_flight_sales` содержит столько строк, сколько в `ods.segments`
7. Нейминг консистентен: `_bk`, `_sk`, `valid_from`/`valid_to`, `hashdiff`, `_load_id`, `_load_ts`, `created_at`/`updated_at` 7. Нейминг консистентен: `_bk`, `_sk`, `valid_from`/`valid_to`, `hashdiff`, `_load_id`, `_load_ts`, `created_at`/`updated_at`
8. `make fmt` / `make lint` проходят 8. `make fmt` / `make lint` проходят
@@ -983,5 +1059,5 @@ ORDER BY r.route_bk, r.valid_from;
## 15) Что будет следующим шагом ## 15) Что будет следующим шагом
- Data Mart (витрина) поверх DDS - Data Mart (витрина) поверх DDS
- Point-in-time lookup для `dim_routes` в факте (`BETWEEN valid_from AND valid_to`) - Unknown-member стратегия (`*_sk = 0`) для late-arriving dimensions
- `dim_calendar.is_holiday` (если появится источник) - `dim_calendar.is_holiday` (если появится источник)