- Зачем:
- план routes-to-reference выполнен, нужен план следующего этапа — валидационный DAG
- Что:
- перемещён docs/plans/2026-03-11_routes-to-reference.md → docs/archive/
- создан docs/plans/2026-03-12_validation-dag.md: структура DAG (17 SQL + DAG + smoke-тест),
активный тест SCD2 (цепочка тасков с безопасным откатом), прошёл 5 раундов ревью ChatGPT
- Проверка:
- make test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
29 KiB
Plan: Перенос STG в эталон, ODS airplanes+seats — студенту
Версия: 4 (после третьего ревью ChatGPT 5.4) Дата: 2026-03-11
Контекст и мотивация
Обнаруженная проблема
При подготовке к Этапу 4 (раскладка по веткам main / solution) обнаружен конфликт
между текущим дизайном БД (docs/design/db_schema.md, docs/design/bookings_dds_design.md)
и планом курсового задания (docs/design/assignment_design.md).
Суть конфликта: эталонный пайплайн (который должен работать «из коробки» на main-ветке
для студента) неработоспособен, если студент ещё не реализовал ни одного задания.
Три DDS-измерения (dim_routes, dim_passengers, dim_airplanes) отнесены к заданию
студента, но эталонная фактовая таблица fact_flight_sales зависит от них через
цепочку LEFT JOIN. Без этих измерений факт загружается с массовыми NULL в ключевых FK,
а DQ-проверки блокируют pipeline. Эталонные витрины (sales_report, airport_traffic)
становятся бесполезными.
Проблема усугубляется архитектурой batch resolver ODS DAG и тестовыми контрактами, которые требуют данных во всех snapshot-таблицах STG.
Ниже — детальный разбор и выбранное решение.
Фактовая таблица: зависимость от студенческих измерений
fact_flight_sales зависит от всех 7 измерений DDS,
включая 3 студенческих: dim_routes, dim_passengers, dim_airplanes.
Критическая цепочка: аэропорты в факте разрешаются через dim_routes:
flt.route_no → dim_routes.route_bk → rte.departure_airport → dim_airports.airport_bk → airport_sk
Если dim_routes пуст (студент ещё не реализовал), то NULL получают не только
route_sk, но и оба airport_sk и airplane_sk. Эталонная витрина sales_report
становится бесполезной.
Причина: ods.flights содержит только route_no, но не departure_airport /
arrival_airport / aircraft_code. Эти поля доступны только через таблицу routes.
Почему нельзя просто использовать DDL-заглушки
Если dim_routes — пустая заглушка, все LEFT JOIN через неё возвращают NULL.
Факт загружается, но:
departure_airport_sk = NULL→sales_reportне может группировать по аэропортамarrival_airport_sk = NULL→airport_trafficне может считать трафик- UPDATE факта не перезаписывает SK (by design, строка 4-5 load-скрипта) → NULL-и остаются навсегда до TRUNCATE + перезагрузки
Дополнительные архитектурные ограничения (из ревью)
-
Fact DQ блокирует загрузку (
fact_flight_sales_dq.sql):- Строка 71:
passenger_sk IS NULL→ hard fail (100% NULL → exception) - Строка 98:
route_sk / airplane_sk IS NULL→ при >1% строк → exception - На main с пустыми студенческими dims — 100% NULL → pipeline падает
- Строка 71:
-
Batch resolver ODS DAG (
bookings_to_gp_ods.py:59-77):INTERSECTпоstg.airports,stg.airplanes,stg.routes,stg.seats- Если любая STG-таблица пуста — общего batch нет → весь ODS pipeline падает
- Контракт зафиксирован тестом
test_ods_sql_contract.py:30
Выбранное решение
Весь STG-слой — эталонный (все 9 таблиц загружаются reference-кодом). Это гарантирует:
- Batch resolver всегда находит согласованный batch (все 4 snapshot-таблицы заполнены)
- DAG-зависимости и smoke-тесты STG не требуют изменений
- Никакого расхождения batch-контракта между main и solution
ODS: airplanes + seats остаются заданием студента (TRUNCATE+INSERT практика).
DDS: dim_airplanes, dim_passengers, dim_routes — задание студента (DDL-заглушки).
Fact lookup: аэропорты через ods.routes (эталон); airplane_sk остаётся
point-in-time через dim_routes (NULL на main — допустимо).
Fact DQ: на main ослабить проверки студенческих SK.
Что теряет студент (и почему это приемлемо)
| Потеря | Компенсация |
|---|---|
| STG-практика (PXF external tables) | Изучает эталонный STG-код; отдельный PXF-практикум в бэклоге (assignment_design.md, раздел 6) |
ods.routes (TRUNCATE+INSERT) |
Остаются ods.airplanes + ods.seats — тот же паттерн |
Ключевые элементы задания сохранены:
- ODS: 2 таблицы (airplanes, seats) — практика TRUNCATE+INSERT
- DDS:
dim_airplanes(SCD1),dim_passengers(SCD1),dim_routes(SCD2) — ключевой вызов - DM: 4 витрины разной сложности (от простой к сложной)
Пересчёт факта после реализации студентом
После реализации всех измерений студенту нужно:
- Запустить загрузку DDS-измерений (dim_airplanes, dim_passengers, dim_routes)
TRUNCATE dds.fact_flight_sales;- Перезапустить загрузку факта → теперь все SK заполнены
- Перезапустить DM-витрины
Это стандартная практика при late-arriving dimensions и хорошая обучающая точка.
airport_traffic — студенческое задание
airport_traffic архитектурно не зависит от студенческих dims (использует только
calendar_sk, departure_airport_sk, arrival_airport_sk). Но это не значит,
что витрина готова: SQL витрины пишет студент. Данные в факте есть (эталон),
студент пишет агрегирующий SQL. Статус: задание студента (как и остальные 3 DM).
Матрица зависимостей DM-витрин
| DM витрина | calendar | airports | tariffs | routes | passengers | airplanes | Работает без студ. dims? |
|---|---|---|---|---|---|---|---|
sales_report (эталон) |
+ | + | + | — | — | — | ДА |
airport_traffic (студент) |
+ | + | — | — | — | — | Данные есть, SQL пишет студент |
route_performance (студент) |
+ | — | — | + | — | — | Нет |
monthly_overview (студент) |
+ | — | — | + | + | + | Нет |
passenger_loyalty (студент) |
+ | — | + | + | + | — | Нет |
Изменения (код)
1. sql/dds/fact_flight_sales_load.sql — lookup аэропортов через ods.routes
Ветка: chore/bookings-etl (сейчас) + main (Этап 4)
Текущее состояние (строки 51-62):
LEFT JOIN dds.dim_routes AS rte
ON rte.route_bk = flt.route_no
AND flt.scheduled_departure::DATE >= rte.valid_from
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
...
LEFT JOIN dds.dim_airports AS dep
ON dep.airport_bk = rte.departure_airport -- через dim_routes!
LEFT JOIN dds.dim_airports AS arr
ON arr.airport_bk = rte.arrival_airport -- через dim_routes!
LEFT JOIN dds.dim_airplanes AS ap
ON ap.airplane_bk = rte.airplane_code -- через dim_routes!
После изменения:
-- Учебный комментарий: Airport lookup через ods.routes (эталонный справочник),
-- а не через dds.dim_routes (студенческое задание SCD2).
-- Это архитектурное решение: эталонный пайплайн работает независимо от студенческого кода.
-- ROW_NUMBER по validity DESC: выбираем актуальную версию расписания маршрута.
-- Аэропорты вылета/прилёта одинаковы во всех версиях одного route_no.
LEFT JOIN (
SELECT route_no, departure_airport, arrival_airport
FROM (
SELECT route_no, departure_airport, arrival_airport,
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
FROM ods.routes
) ranked
WHERE rn = 1
) AS ods_rte ON ods_rte.route_no = flt.route_no
LEFT JOIN dds.dim_routes AS rte
ON rte.route_bk = flt.route_no
AND flt.scheduled_departure::DATE >= rte.valid_from
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
...
LEFT JOIN dds.dim_airports AS dep
ON dep.airport_bk = ods_rte.departure_airport -- через ods.routes (эталон)
LEFT JOIN dds.dim_airports AS arr
ON arr.airport_bk = ods_rte.arrival_airport -- через ods.routes (эталон)
LEFT JOIN dds.dim_airplanes AS ap
ON ap.airplane_bk = rte.airplane_code -- через dim_routes (point-in-time, как прежде)
Почему аэропорты через ods.routes, а airplane через dim_routes:
-
Аэропорты:
departure_airportиarrival_airportодинаковы во всех версиях одногоroute_no(маршрут SVO→LED всегда SVO→LED). Безопасно брать из ODS. Это даёт эталонным витринам (sales_report,airport_traffic) корректныеairport_skдаже без студенческогоdim_routes. -
Самолёт:
airplane_codeтеоретически может отличаться между версиями маршрута. Текущий контракт DDS (bookings_dds_design.md:499) фиксирует, чтоairplane_skприходит из той же point-in-time версии маршрута, что иroute_sk. Менять эту семантику — изменение модели данных, а не стабилизация. На mainairplane_skбудет NULL (dim_routes — заглушка). Это допустимо: ниsales_report, ниairport_trafficне используютairplane_sk.
2. sql/dds/fact_flight_sales_dq.sql — ослабить проверки студенческих SK (только main)
Ветка: только main (Этап 4). На solution-ветке полные проверки сохраняются.
Изменения:
-
Строки 65-75 (
passenger_sk IS NULL): заменитьRAISE EXCEPTIONнаRAISE NOTICE. На maindim_passengers— заглушка, 100% строк будут сpassenger_sk IS NULL. Любой порог (даже >1%) даст exception. Только логирование, без блокировки. -
Строки 89-108 (route-related FK): разделить на два блока:
Блок A — эталонные FK (проверка с порогом 1%, как calendar_sk):
-- departure_airport_sk и arrival_airport_sk заполняются через ods.routes (эталон). -- NULL здесь — аномалия данных (пропущен маршрут в ODS), а не отсутствие студенческого кода. -- Порог 1% — защита от единичных аномалий источника (аналогично calendar_sk). SELECT COUNT(*) INTO v_null_airport FROM dds.fact_flight_sales WHERE departure_airport_sk IS NULL OR arrival_airport_sk IS NULL; IF v_null_airport > 0 THEN IF v_null_airport * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN RAISE EXCEPTION 'DQ FAILED: NULL airport_sk: % (>1%%)', v_null_airport; ELSE RAISE NOTICE 'DQ WARNING: NULL airport_sk: % (<=1%%, допустимо)', v_null_airport; END IF; END IF;Блок B — студенческие FK (только логирование, RAISE NOTICE):
-- route_sk и airplane_sk будут NULL, пока студент не реализует dim_routes. -- Не блокируем pipeline. SELECT COUNT(*) INTO v_null_student FROM dds.fact_flight_sales WHERE route_sk IS NULL OR airplane_sk IS NULL; IF v_null_student > 0 THEN RAISE NOTICE 'DQ INFO: студенческие SK (route/airplane) NULL: %. ' 'После реализации dim_routes: TRUNCATE fact → перезагрузка.', v_null_student; END IF;
Итоговое правило на main:
departure_airport_sk,arrival_airport_sk— проверка с порогом 1% (эталон, через ods.routes; >1% → EXCEPTION, <=1% → NOTICE)passenger_sk,route_sk,airplane_sk— только RAISE NOTICE (студенческие заглушки, 100% NULL допустимо)
-- Учебный комментарий: route_sk, airplane_sk и passenger_sk будут NULL,
-- пока вы не реализуете соответствующие DDS-измерения.
-- После реализации: TRUNCATE dds.fact_flight_sales → перезагрузка → все SK заполнены.
-- Полную версию DQ (с блокировкой) см. в ветке solution.
3. ODS Routes DQ: убрать RI-проверку airplane_code (только main)
Ветка: только main (Этап 4). На solution-ветке полные проверки сохраняются.
Почему только ODS, не STG? Весь STG — эталон,
stg.airplanesвсегда заполнен,stg/routes_dq.sqlRI-проверка кstg.airplanesпроходит штатно. Менять STG не нужно. На уровне ODSods.airplanes— студенческая заглушка (пустая), поэтомуods/routes_dq.sqlRI-проверка кods.airplanesупадёт.
Файл:
sql/ods/routes_dq.sql— удалить блок строк 143-156 (RI кods.airplanes)
Добавить комментарий:
-- Учебный комментарий: проверка RI airplane_code → ods.airplanes не выполняется,
-- т.к. таблица ods.airplanes реализуется студентом. После реализации — раскомментируйте
-- (см. ветку solution для полной версии).
4. ODS DAG-зависимость: убрать airplanes → routes (только main)
Ветка: только main (Этап 4)
Почему только ODS, не STG? STG DAG не меняется:
stg.airplanes— эталон, барьерcheck_airplanes_dq >> load_routes_to_stgработает штатно. На уровне ODSdq_ods_airplanes— заглушка (SELECT 1;), формально проходит, но зависимость вводит в заблуждение. Убираем для ясности.
airflow/dags/bookings_to_gp_ods.py (строка 266):
# Было:
[dq_ods_airports, dq_ods_airplanes] >> load_ods_routes
# Стало:
dq_ods_airports >> load_ods_routes
5. Заглушки для студенческих файлов (только main)
Ветка: только main (Этап 4)
STG-слой: весь код остаётся (эталон), заглушки не нужны
Все 9 STG-таблиц загружаются эталонным кодом. Batch resolver работает без изменений.
ODS-слой: заглушки для airplanes + seats
| Файл | main (студент) | solution |
|---|---|---|
sql/ods/airplanes_ddl.sql |
Полный DDL (таблица нужна) | Тот же |
sql/ods/airplanes_load.sql |
Заглушка: SELECT 1; -- TODO |
Полная реализация |
sql/ods/airplanes_dq.sql |
Заглушка: SELECT 1; |
Полная реализация |
sql/ods/seats_ddl.sql |
Полный DDL | Тот же |
sql/ods/seats_load.sql |
Заглушка | Полная реализация |
sql/ods/seats_dq.sql |
Заглушка | Полная реализация |
DDS-слой: заглушки для 3 измерений
| Файл | main (студент) | solution |
|---|---|---|
sql/dds/dim_routes_ddl.sql |
Полный DDL (нужен для LEFT JOIN) | Тот же |
sql/dds/dim_routes_load.sql |
Заглушка: SELECT 1; -- TODO: реализуйте SCD2 |
Полная реализация |
sql/dds/dim_routes_dq.sql |
Заглушка: SELECT 1; |
Полная реализация |
sql/dds/dim_passengers_ddl.sql |
Полный DDL | Тот же |
sql/dds/dim_passengers_load.sql |
Заглушка | Полная реализация |
sql/dds/dim_passengers_dq.sql |
Заглушка | Полная реализация |
sql/dds/dim_airplanes_ddl.sql |
Полный DDL | Тот же |
sql/dds/dim_airplanes_load.sql |
Заглушка | Полная реализация |
sql/dds/dim_airplanes_dq.sql |
Заглушка | Полная реализация |
DM-слой: заглушки для 4 витрин
Аналогично: DDL остаётся, load/dq заменяются заглушками.
Файлы: airport_traffic, route_performance, monthly_overview, passenger_loyalty.
Изменения (документация)
6. docs/assignment/analyst_spec.md
- Удалить разделы 1.1-1.3 (все STG-задания) — весь STG теперь эталон
- Удалить разделы 2.3 (
ods.routes) — routes теперь эталон - Обновить рекомендуемый порядок: начинается с ODS (airplanes, seats)
- Добавить мотивационную секцию «Почему STG уже реализован»:
- Эталонный пайплайн требует согласованного batch по всем snapshot-таблицам
- Без данных в STG невозможна загрузка ODS и далее по цепочке
- Студент изучает эталонный STG-код как образец
- Добавить секцию «Пересчёт факта после реализации измерений»:
- Инструкция: TRUNCATE fact + re-run после реализации всех DDS-измерений
- Педагогическая ценность: late-arriving dimensions, dependency management
7. docs/design/assignment_design.md
- Обновить таблицу «Эталонные таблицы»: добавить весь STG + ods.routes
- Обновить таблицу «Задание студенту»: убрать STG целиком, ODS оставить airplanes+seats
- Обновить рекомендуемый порядок: начинается с ODS
- Обновить структуру валидационного DAG (раздел 4): убрать
validate_stgсекцию
8. docs/design/bookings_dds_design.md
- Строки 463, 468 (SQL-скелет факта): обновить JOIN-блок —
dep.airport_bkиarr.airport_bkтеперь черезods_rte, а не черезrte;ap.airplane_bkостаётся черезrte(point-in-time) - Строка 499-501 (текстовое пояснение): обновить —
departure_airport_skиarrival_airport_skберутся изods.routes(эталон);airplane_skостаётся point-in-time черезdim_routes - Строка 558 (passenger_sk = 0): убрать утверждение о нулевой толерантности —
на main
passenger_skбудет 100% NULL (dim_passengers — заглушка) - Строка 559 (DQ текстовое описание): обновить — на main student SK
(
passenger_sk,route_sk,airplane_sk) не блокируют pipeline;airport_skпроверяются с порогом 1% - Строка 741 (DQ SQL-пример): обновить встроенный SQL — разделить route-related блок на эталонный (airport_sk, порог 1%) и студенческий (route_sk, airplane_sk, NOTICE)
9. docs/design/db_schema.md
- Пометить весь STG и
ods.routesкак эталонные (не студенческие) - Строка 3: «Все слои реализованы» — уточнить, что на main ODS airplanes/seats, DDS student dims и DM student vitrines — заглушки
- Строка 253: DQ-контракт «SQL-скрипты с RAISE EXCEPTION» — добавить оговорку, что на main студенческие DQ-скрипты являются заглушками
10. Тесты
tests/test_dags_smoke.pyстроки 242-243: На main убрать assert барьераdq_ods_airplanes → dq_ods_routes(ODS routes не зависит от ODS airplanes на main). STG-барьеры (строки 123-124) не трогаем: STG целиком эталонный, барьерcheck_airplanes_dq → check_routes_dqработает.tests/test_ods_sql_contract.pyстрока 6: На main изменитьSNAPSHOT_ENTITIES— исключить студенческие ODS:Тесты# Было: SNAPSHOT_ENTITIES = ("airports", "airplanes", "routes", "seats") # Стало (main): SNAPSHOT_ENTITIES = ("airports", "routes")test_snapshot_load_scripts_use_truncate(строка 13) иtest_snapshot_dq_checks_extra_keys(строка 22) проверяют паттерны TRUNCATE+INSERT иv_extra_keys_countв SQL-файлах. ЗаглушкиSELECT 1;не содержат этих паттернов → тесты упадут для airplanes/seats. Исключение изSNAPSHOT_ENTITIESрешает проблему. Batch resolver тест (строка 30) проверяет DAG-код (не SQL-файлы) — без изменений.
11. Прочие документы
docs/design/PRD.md— обновить чеклист (раздел 10)docs/design/bookings_ods_design.md— комплексное обновление:- Строка 29, 515, 534: пометить
airplanesиseatsкак студенческие (на main — заглушки; STG-уровень — эталон, ODS load/dq — студент) - Строки 521, 523: скорректировать общий DQ-контракт — на main
airplanes_dq.sqlиseats_dq.sqlявляются заглушками, а не полноценными RAISE EXCEPTION скриптами - Строка 620: обновить DAG-граф — routes больше не зависит от airplanes на ODS
- Строка 634: убрать зависимость routes от airplanes в описании FK
- Строка 29, 515, 534: пометить
docs/bookings_to_gp_ods.md— обновить описаниеdocs/bookings_to_gp_dds.md:- Строки 111-113: убрать гарантию отсутствия NULL SK в штатном режиме —
на main student SK (
passenger_sk,route_sk,airplane_sk) будут NULL - Строка 116: обновить DQ-семантику — student SK не блокируют (NOTICE); airport_sk — порог 1%
- Строка 126: обновить описание lookup (airports через ods.routes)
- Строки 111-113: убрать гарантию отсутствия NULL SK в штатном режиме —
на main student SK (
docs/reference/qa-plan.md:- Строка 80: «ODS: все 9 таблиц не пустые» — на main
ods.airplanesиods.seatsбудут пустыми by design (студенческие заглушки). Уточнить формулировку.
- Строка 80: «ODS: все 9 таблиц не пустые» — на main
docs/e2e-etl-test-protocol.md— обновить контекст
Порядок выполнения
Сейчас (на chore/bookings-etl)
- Код: изменить
fact_flight_sales_load.sql(lookup через ods.routes) — п.1 - Документация: обновить
analyst_spec.md— п.6 - Документация: обновить
assignment_design.md— п.7 - Документация: обновить
db_schema.md— п.9 - Тестирование:
make test+ ручная проверка SQL - Коммит
Этап 3 (валидационный DAG)
- Проектировать с учётом DDL-заглушек
validate_stgсекцию не делать (STG — эталон)check_dim_routes_scd2— активный тест (мутация ODS → проверка → откат)
Этап 4 (подготовка main + solution)
- Fact DQ: ослабить проверки student SK (main only) — п.2
- Routes DQ: убрать airplane RI (main only) — п.3
- DAG-зависимости: убрать airplanes → routes (main only) — п.4
- Заглушки: ODS (airplanes, seats) + DDS (3 dims) + DM (4 vitrines) — п.5
- Тесты: обновить smoke-тесты (main only) — п.10
- Документация: bookings_dds_design, PRD, прочее — п.8, п.11
Верификация
После п.1 (fact_flight_sales fix)
make test # smoke-тесты DAG
Ручная проверка (при поднятом стенде):
Важно: UPDATE факта не перезаписывает dimension SK (by design, строка 4-5). Уже загруженные строки сохранят старые значения
airport_sk(через dim_routes). Для полной проверки новой логики нужен TRUNCATE + перезагрузка:
TRUNCATE dds.fact_flight_sales;
-- Перезапустить DDS DAG (или только task load_dds_fact_flight_sales)
-- Проверка: airport_sk заполнены (через ods.routes), route_sk тоже (dim_routes заполнен на этой ветке)
SELECT
COUNT(*) AS total,
COUNT(departure_airport_sk) AS has_dep_sk,
COUNT(arrival_airport_sk) AS has_arr_sk,
COUNT(route_sk) AS has_route_sk
FROM dds.fact_flight_sales;
После Этапа 4 (main branch)
make up→make ddl-gp→ запустить STG+ODS+DDS DAG-и- Проверить:
sales_reportсодержит данные с корректными аэропортами - Проверить: студенческие ODS/DDS-таблицы пусты (заглушки)
- Fact DQ проходит (student SK = NULL, но DQ не блокирует)
- Реализовать один студенческий dim → TRUNCATE fact → re-run → проверить SK
Ключевые файлы
| Файл | Роль | Ветка |
|---|---|---|
sql/dds/fact_flight_sales_load.sql |
Главное изменение: airport lookup через ods.routes | обе |
sql/dds/fact_flight_sales_dq.sql |
Student SK: EXCEPTION → NOTICE | main only |
sql/ods/routes_dq.sql |
Убрать RI airplane_code → ods.airplanes | main only |
airflow/dags/bookings_to_gp_ods.py |
DAG: убрать airplanes → routes | main only |
tests/test_ods_sql_contract.py |
SNAPSHOT_ENTITIES: убрать airplanes, seats | main only |
tests/test_dags_smoke.py |
ODS-барьер airplanes → routes | main only |
docs/assignment/analyst_spec.md |
Убрать STG-задания, убрать ods.routes | обе |
docs/design/assignment_design.md |
Обновить таблицы эталон/задание | обе |
docs/design/bookings_dds_design.md |
Обновить описание fact lookup | обе |
docs/design/bookings_ods_design.md |
Убрать зависимость routes от airplanes | main only |
docs/design/db_schema.md |
Пометить STG + ods.routes как эталон | обе |
STG не трогаем:
sql/stg/routes_dq.sql,bookings_to_gp_stage.py, STG smoke-тесты — без изменений. Весь STG эталонный, зависимости работают штатно.