Author SHA1 Message Date
ddadmin 7bf61d8f74 docs(labs): зафиксировано развитие учебной программы
Описаны аудитория, обязательная лабораторная по приему справочника, предложения по закрытию пробелов и критерии подготовки заданий. Оценка и ссылки на учебный код относятся к main.

Проверка: новые ссылки сверены по локальным веткам, git diff --cached --check пройден.
2026-09-22 09:20:48 -04:00
ddadminandClaude Opus 4.6 999a8d08bf docs(solution): закрыт Этап 4 в TODO — раскладка main/solution выполнена
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 19:23:46 +03:00
ddadminandClaude Opus 4.6 d3f2f42e38 docs(solution): маркетинг-баннер, шаг 7, навигатор по реализации
- Зачем:
  - на solution не было явного «что дальше» после шага 6 и навигации по паттернам.
- Что:
  - README: маркетинг-баннер + шаг 7 «Изучите готовую реализацию».
  - docs/assignment/README: навигатор по всем паттернам (ODS → DDS → DM) с файлами.
  - архивирован план онбординга в docs/archive/.
- Проверка:
  - make test (4 passed), make lint (clean).
  - grep «заглушки/SELECT 1» в README и docs/assignment/README — не найдено.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:07:52 +03:00
ddadminandClaude Opus 4.6 a8148c8afa docs: обновлена документация — airport lookup через ods.routes
Описания fact_flight_sales_load обновлены в bookings_dds_design,
bookings_to_gp_dds и db_schema: departure/arrival_airport_sk теперь
разрешаются через ods.routes → dim_airports, а не через dim_routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:51:55 +03:00
ddadmin f053fe1e35 feat(etl): полный эталонный пайплайн STG→ODS→DDS→DM
# Conflicts:
#	TODO.md
2026-03-12 22:45:36 +03:00
ddadminandClaude Opus 4.6 5cb4bab010 docs(plans): добавлен план раскладки main/solution (Этап 4)
- Зачем:
  - нужна стратегия ветвления и пошаговый план для финального этапа подготовки курсовой
- Что:
  - создан `docs/plans/2026-03-12_main-solution-split.md` (v4, после 4 ревью Codex)
  - стратегия: мерж в main → общие правки → ветка solution → заглушки на main
  - solution как source of truth, однонаправленный поток solution → main
  - план очистки docs для студентов (удалить plans/archive/PRD с main)
  - открытый вопрос: детальный протокол синхронизации веток
  - добавлена пометка статуса в архивный план routes-to-reference
- Проверка:
  - просмотр `docs/plans/2026-03-12_main-solution-split.md`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:36:32 +03:00
ddadminandClaude Opus 4.6 330911c0f1 fix(sql): добавлены недостающие DM-таблицы в truncate_gp.sql
- Зачем:
  - make dwh-truncate очищал только dm.sales_report, остальные 4 витрины
    сохраняли старые SK → DQ падал при перезагрузке DDS с новыми ключами.
- Что:
  - добавлены route_performance, airport_traffic, monthly_overview,
    passenger_loyalty в TRUNCATE блок DM.
- Проверка:
  - make dwh-truncate + полный прогон пайплайна (STG→ODS→DDS→DM→validate).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:45:21 +03:00
ddadminandClaude Opus 4.6 7b6136b0fa fix(stg): переведён boarding_passes на инкрементальную загрузку
- Зачем:
  - full snapshot boarding_passes при повторных запусках создавал orphan-записи
    без соответствующих tickets/segments (инкрементальных), DQ корректно падал.
- Что:
  - boarding_passes_load.sql: HWM через book_date (JOIN tickets_ext → bookings_ext),
    аналогично segments_load.sql.
  - boarding_passes_dq.sql: подсчёт источника с фильтром по окну инкремента,
    обработка пустого окна (NOTICE + RETURN).
  - обновлена документация (4 файла): db_schema, bookings_to_gp_stage,
    bookings_ods_design, inline-комментарий в DAG.
- Проверка:
  - make test (4 passed), статический ревью Codex CLI (0 замечаний по SQL).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:11:40 +03:00
ddadmin d6e5cd56c6 docs(plans): архивирован план validation-dag после реализации 2026-03-12 12:12:57 +03:00
ddadmin a51e2a6243 feat(validate): добавлен валидационный DAG bookings_validate
- Зачем:
  - студент реализует 9 объектов DWH без автоматической обратной связи;
  - ошибки (NULL в PK, дубли BK, сломанный SCD2) обнаруживались только на слое DM
    через 2–3 слоя, где отладка многократно сложнее.
- Что:
  - создан DAG bookings_validate.py — три параллельных TaskGroup (validate_ods,
    validate_dds, validate_dm), schedule=None (только ручной trigger);
  - создано 17 SQL-скриптов в sql/validate/: ODS BK coverage vs STG-батч,
    согласованность _load_id между ods.airplanes и ods.seats, дубли BK, NULL в PK,
    покрытие ODS→DDS для измерений (dim_routes требует открытой версии valid_to IS NULL),
    SCD2 активный тест backup→mutate→student_load→check(5 инвариантов)→restore
    (trigger_rule=all_done), проверка «дыр» между SCD2-версиями,
    exists-чеки для 4 DM-витрин;
  - добавлен класс TestBookingsValidate (5 smoke-тестов) в tests/test_dags_smoke.py.
- Проверка:
  - make test — 4 passed, 14 skipped (DAG-тесты скипаются без Airflow, норма);
  - ручной trigger bookings_validate в Airflow UI на solution-ветке — все таски зелёные.
2026-03-12 12:12:44 +03:00
ddadminandClaude Opus 4.6 23cde47fe7 docs(plans): добавлен план валидационного DAG, архивирован routes-to-reference
- Зачем:
  - план 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>
2026-03-12 11:50:03 +03:00
ddadminandClaude Sonnet 4.6 4e9d8f5a1c feat(dds): перенесён lookup аэропортов на ods.routes, обновлена документация задания
- Зачем:
  - fact_flight_sales брал airport_sk через dds.dim_routes (студенческое задание SCD2);
    на ветке main без реализованного dim_routes все airport_sk были NULL,
    и эталонные витрины sales_report/airport_traffic становились бесполезны.
- Что:
  - fact_flight_sales_load.sql: добавлен subquery ods_rte (ROW_NUMBER по ods.routes),
    dep/arr airport_sk теперь через ods.routes (эталон); airplane_sk — через dim_routes (point-in-time).
  - analyst_spec.md: удалены STG-задания (1.1–1.3) и ods.routes (2.3), добавлены секции
    «Почему STG уже реализован» и «Пересчёт факта», исправлен ODS intro, перенумерованы части.
  - assignment_design.md: обновлены таблицы эталон/задание, порядок выполнения; убраны
    validate_stg и check_ods_routes_rowcount из структуры валидационного DAG.
  - db_schema.md: статус уточнён («реализовано в ветке solution»), добавлена оговорка
    о заглушках на main, DQ-контракт дополнен примечанием.
- Проверка:
  - make test → 4 passed, 9 skipped.
  - ручная проверка: TRUNCATE dds.fact_flight_sales → перезапуск DDS DAG →
    COUNT(departure_airport_sk) должен равняться COUNT(*).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 10:39:19 +03:00
ddadmin f018f1e7da docs(plans): добавлен план переноса STG в эталон и разделения main/solution
- Зачем:
  - обнаружен конфликт между дизайном БД и планом курсового задания:
    эталонный пайплайн неработоспособен на main без студенческих измерений.
- Что:
  - создан docs/plans/2026-03-11_routes-to-reference.md (v4, после 3 раундов ревью).
  - добавлен бэклог PXF-практикума в assignment_design.md (раздел 6).
- Проверка:
  - прочитать план, убедиться в согласованности формулировок.
2026-03-11 23:54:53 +03:00
ddadmin e699934230 docs(design): обновлена db_schema.md и архивирован architecture_review.md
- Зачем:
  - db_schema.md не отражала DM-слой и содержала неточности в метаданных таблиц.
  - architecture_review.md устарел: все P0–P2 выполнены, P3 отложены и покрыты другими документами.
- Что:
  - db_schema.md переработана: Mermaid-диаграмма потоков перенесена в начало, добавлен DM-слой, STG/ODS/DDS/DM представлены компактными таблицами.
  - исправлены неточности: dim_calendar (AO Column → AO Row), dim_tariffs (INSERT-only → SCD1), ODS добавлено поле event_ts.
  - architecture_review.md перенесён из docs/design/ в docs/archive/, статус обновлён на «завершён».
- Проверка:
  - открыть docs/design/db_schema.md и убедиться, что диаграмма в начале файла, DM-слой отражён.
2026-03-11 22:20:09 +03:00
ddadmin c9856f0c95 docs(design): добавлено описание активной проверки SCD2 для dim_routes
- Зачем:
  - необходимо гарантировать проверку логики SCD2 в учебном проекте, так как исходные данные статичны и не вызывают срабатывание SCD2 естественным образом.
- Что:
  - добавлен раздел "Активная проверка SCD2" в дизайн задания (assignment_design.md).
  - описан алгоритм с использованием временных таблиц для тестирования закрытия старых и открытия новых версий записей.
- Проверка:
  - визуальный осмотр обновленного раздела в docs/design/assignment_design.md.
2026-03-11 22:01:13 +03:00
ddadminandClaude Opus 4.6 84454debe3 docs(assignment): добавлено ТЗ от аналитика для курсового задания
- Зачем:
  - студенту нужен реалистичный документ-спецификация с маппингами, типами
    и бизнес-правилами для самостоятельной реализации DWH-пайплайна.
- Что:
  - создан docs/assignment/analyst_spec.md — полное ТЗ на 4 слоя (STG, ODS, DDS, DM).
  - STG: airplanes, seats, routes (snapshot, маппинг из PXF-источника).
  - ODS: airplanes, seats, routes (TRUNCATE+INSERT, приведение типов).
  - DDS: dim_airplanes, dim_passengers (SCD1), dim_routes (SCD2 с пошаговым алгоритмом).
  - DM: airport_traffic, route_performance, monthly_overview, passenger_loyalty.
  - обновлён docs/assignment/README.md — ссылка на спецификацию вместо заглушки.
- Проверка:
  - визуальная сверка маппингов с sql/stg/, sql/ods/, sql/dds/, sql/dm/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:22:40 +03:00
ddadmin 8932148c44 chore(docs): введена конвенция нейминга планов, переименованы архивные файлы
- Зачем:
  - файлы планов именовались хаотично (микс snake_case/kebab-case, без дат),
    из-за чего архив не сортировался хронологически.
- Что:
  - установлен формат YYYY-MM-DD_краткое-описание.md для docs/plans/ и docs/archive/.
  - переименованы 6 архивных файлов по новой конвенции (git mv).
  - конвенция зафиксирована в AGENTS.md (секция «Карта проекта»).
- Проверка:
  - ls docs/archive/ — все файлы начинаются с даты в kebab-case.
2026-03-11 19:34:45 +03:00
ddadmin 4ce0aa7a23 docs(etl): расширена документация ETL DAG-ов ODS, DDS, DM
- Зачем:
  - документация трёх DAG-ов была значительно беднее эталона (bookings_to_gp_stage.md):
    отсутствовал пошаговый разбор задач, SQL-пути и ASCII-графы зависимостей.
- Что:
  - добавлены секции "Как это работает внутри" с таблицами task_id → SQL-файл → паттерн.
  - добавлены ASCII-графы зависимостей в ODS и DDS (в DM уже был).
  - исправлено описание паттернов ODS: явно разделены TRUNCATE+INSERT для AO snapshot-справочников и SCD1 UPSERT для транзакционных таблиц.
  - исправлено описание fact_flight_sales: убрана неточная отсылка к late-arriving dimensions, добавлено объяснение defensive LEFT JOIN и точных DQ-правил (0% / 1%).
  - добавлена таблица grain и описание стратегий загрузки для каждой DM-витрины.
  - план улучшений перенесён в docs/archive/.
- Проверка:
  - визуально: открыть каждый docs/bookings_to_gp_*.md и убедиться, что секции присутствуют.
2026-03-11 19:26:29 +03:00
ddadmin f157c9142b docs(agents): добавлено описание структуры docs/ в карту проекта
- Зачем:
  - после реструктуризации docs/ агенты не знали о новых каталогах
    design/, reference/, plans/, archive/, assignment/.
- Что:
  - в раздел «Карта проекта» добавлена строка со структурой docs/.
- Проверка:
  - читаемость AGENTS.md, раздел 2.
2026-03-10 23:51:42 +03:00
ddadmin 21f62be01a docs(todo): исправлен порядок этапов подготовки курсовой
- Зачем:
  - Этап «подготовка main» не должен идти раньше ТЗ и валидационного DAG —
    нельзя удалять рабочий пайплайн до того, как всё создано и протестировано.
- Что:
  - Этапы 2-5 переупорядочены: сначала ТЗ от аналитика, затем валидационный DAG,
    в конце — подготовка main и создание ветки solution.
  - Этапы 2 и 5 (подготовка main + solution) объединены в один финальный этап.
  - Добавлены пояснения «Делаем пока полный пайплайн работает» к каждому этапу.
- Проверка:
  - Читаемость TODO.md.
2026-03-10 23:13:48 +03:00
ddadmin 6655326caa 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/*' — должно быть пусто.
2026-03-10 23:02:41 +03:00
ddadmin 5972bcc2d8 refactor(all): унифицированы служебные поля STG — переход на канонический нейминг
- Зачем:
  - STG-слой использовал legacy-имена (batch_id, load_dttm, src_created_at_ts),
    тогда как ODS/DDS/DM уже работали с каноном (_load_id, _load_ts, event_ts).
    Студент видел разные имена для одного понятия — это убрано.
- Что:
  - переименованы колонки в 9 STG DDL: batch_id→_load_id, load_dttm→_load_ts,
    src_created_at_ts→event_ts; добавлен NOT NULL для _load_id во всех таблицах.
  - обновлены 9 STG Load, 9 STG DQ, 9 ODS Load, 9 ODS DQ (INSERT/SELECT/WHERE).
  - обновлены DAG-файлы bookings_to_gp_stage.py и bookings_to_gp_ods.py
    (встроенный SQL резолвера, комментарии; Python-идентификаторы не тронуты).
  - обновлены тесты и ~15 документов (naming_conventions, PRD, db_schema,
    design-docs, qa-plan, README, TESTING и др.).
- Проверка:
  - grep -rn 'load_dttm\|src_created_at_ts' sql/ airflow/ tests/ — 0 совпадений.
  - make test — 4 passed.
  - e2e-etl: day1 прошёл полностью, day2 стартовал без ошибок.
2026-03-10 00:07:08 +03:00
ddadmin 3de4db8aa6 docs(all): ревизия документации перед мержем в main
- Зачем:
  - ветка содержала устаревшие ссылки, артефакты CSV-пайплайна и метки «черновик»
    для полностью реализованных слоёв STG→ODS→DDS→DM.
- Что:
  - AGENTS.md: заменена фраза «в будущем» на перечисление реальных слоёв ODS/DDS/DM.
  - TESTING.md: удалены две строки про каталог data/ (артефакт CSV-пайплайна).
  - README.md: список документации заменён на кликабельные markdown-ссылки, добавлены STG и DM DAG.
  - docs/README.md: добавлен DM DAG в «Быстрый путь», DM design в «Технические детали»; убраны метки «(черновик)».
  - docs/internal/bookings_stg_design.md: убран заголовок «черновик», исправлены описания слоёв и DDL.
  - docs/internal/PRD.md: битая ссылка на analyst_spec.md заменена текстом с пометкой TODO.
  - TODO.md: ссылка на plans/ обновлена на docs/internal/bookings_db_issues.md.
  - docs/bookings_to_gp_dm.md: создан новый документ по аналогии с DDS doc (5 витрин, граф, DQ, ошибки).
  - plans/ и docs/chore/: каталоги удалены (планы выполнены, история сохранена в git).
- Проверка:
  - make lint && make test — прошло чисто.
  - grep -n "черновик|data/|в будущем|plans/" — пустой результат.
2026-03-09 20:59:09 +03:00
ddadminandClaude Opus 4.6 a207f8d281 docs(testing): усилены инструкции по управлению DAG через REST API
- Зачем:
  - агент использовал CLI (`docker exec ... airflow dags unpause`) вместо REST API,
    что приводило к зависанию DAG'ов в статусе `queued`.
- Что:
  - e2e-etl-test-protocol: явный запрет на CLI-команды, REST API как основной метод,
    шаблон unpause+trigger для каждого слоя, пример проверки статуса через API.
  - agent-dag-testing: убрана оговорка «если он новый» — unpause обязателен всегда.
  - TODO: пункт «стенд поднимается на чистой машине» отмечен выполненным.
- Проверка:
  - прочитать docs/e2e-etl-test-protocol.md и docs/agent-dag-testing.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:50:39 +03:00
ddadminandClaude Sonnet 4.6 815a248841 chore(bookings): полировка эталонного среза — код, документация, ANALYZE
- Зачем:
  - подготовка эталонного пайплайна к курсовой: код должен быть чистым,
    документация — актуальной, все слои — консистентными.
- Что:
  - добавлен ANALYZE во все 5 DM load-скриптов (был в STG/ODS/DDS, пропущен в DM).
  - README актуализирован: добавлен DM-слой в быстрый старт, список DAG'ов,
    исправлен дефолт BOOKINGS_JOBS (2→1), добавлены примеры запросов DM.
  - удалены остатки CSV-пайплайна: CSV_DIR/CSV_ROWS из docs/stack.md.
  - docs/stack.md: исправлен дефолт BOOKINGS_JOBS (2→1).
  - убран отладочный комментарий «Исправлено:» в passenger_loyalty_load.sql.
  - исправлено форматирование (black) в 4 файлах.
  - TODO.md: отмечены пройденные пункты Этапа 1.5.
- Проверка:
  - make lint && make test — проходят.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:37:35 +03:00
ddadmin 0ee2d81383 docs(qa-plan): исправлены SQL-запросы по результатам прогона отладки
- Зачем:
  - план содержал неверные имена полей и формулы, которые давали ошибки при выполнении.
- Что:
  - `_batch_id` → `batch_id` в запросах снапшот-таблиц STG (реальное имя колонки).
  - формула SCD2-маршрутов: `COUNT(DISTINCT route_no)` вместо `route_no || '-' || validity`
    (DDS берёт один route_no с последним validity через rn=1).
  - `ticket_price` → `price`, `boarding_seq IS NOT NULL` → `is_boarded = TRUE`
    (реальные колонки dds.fact_flight_sales).
  - Блок 2: добавлено пояснение, что STG-DAG всегда генерирует новый день,
    поэтому тест идемпотентности — только ODS/DDS/DM без повторного запуска STG.
  - Блок 5.2: уточнено, что ODS намеренно использует batch_id из STG вместо run_id
    для сквозного lineage — это паттерн, а не баг.
- Проверка:
  - все SQL-запросы из плана выполнены на стенде без ошибок.
2026-03-09 19:21:32 +03:00
ddadminandClaude Opus 4.6 c81bdb86d9 perf(bookings): BOOKINGS_JOBS=1 по умолчанию (3× быстрее на инкрементах)
- Зачем:
  - бенчмарк на i7-11800H / WSL2 показал, что jobs=1 генерирует +1 день за ~3 мин,
    а jobs=2 — за ~9 мин из-за lock contention на gen.events FOR UPDATE SKIP LOCKED.
    Тюнинг PG (shared_buffers, work_mem) и synchronous_commit=off не влияют.
- Что:
  - дефолт BOOKINGS_JOBS изменён с 2 на 1 в Makefile и .env.example.
  - добавлен docs/internal/bookings_generation_benchmark.md с полной матрицей экспериментов.
  - обновлены комментарии и bookings/README.md.
- Проверка:
  - make bookings-init && time make bookings-generate-day (~3 мин).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:06:56 +03:00
ddadminandClaude Opus 4.6 85d2776b44 fix(bookings): устранено зависание генерации при jobs>1 и убран VACUUM из инкрементов
- Зачем:
  - make bookings-generate-day зависал на 35+ мин в WHILE busy() LOOP при BOOKINGS_JOBS=2,
    а VACUUM ANALYZE всей БД внутри process_queue() добавлял минуты к каждому инкременту.
- Что:
  - заменён опрос busy() (pg_stat_activity) на dblink_is_busy() — прямая проверка
    состояния каждого dblink-соединения, без зависимости от application_name и state.
  - перед continue() удаляются VACUUM-ивенты из gen.events — они бессмысленны для +1 дня.
- Проверка:
  - make bookings-init && make bookings-generate-day BOOKINGS_JOBS=2 (~5-8 мин, без зависания).
  - DAG bookings_to_gp_stage: все 20 тасков success.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:30:13 +03:00
ddadminandClaude Opus 4.6 78fb8e372c fix(bookings): исправлена генерация demodb и добавлен seed-дамп
- Зачем:
  - генерация с нуля занимала часы, boarding_passes были пустыми, данные
    пропадали после docker compose down/up.
- Что:
  - обновлён demodb до коммита 866e56f, добавлен патч install_connstr_no_hardcode.
  - BOOKINGS_INIT_DAYS увеличен до 60, BOOKINGS_JOBS по умолчанию 2.
  - добавлен seed-дамп bookings/seed/demo.sql.xz (42 MB, xz вместо 7z).
  - make bookings-init восстанавливает из дампа (~18 сек) и применяет GUC из .env.
  - make bookings-generate — генерация с нуля для разработчиков.
  - generate_next_day.sql: COMMIT после continue(), pg_sleep(3) для jobs>1, synchronous_commit=on + CHECKPOINT.
  - bookings-check-jobs: добавлена валидация нечисловых значений BOOKINGS_JOBS.
  - bookings-init теперь вызывает bookings-check-jobs как prerequisite.
  - синхронизированы внутренние документы (коммит demodb, init_days=60).
- Проверка:
  - make bookings-init && make bookings-generate-day BOOKINGS_JOBS=2.
  - make test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:01:03 +03:00
ddadminandClaude Opus 4.6 ae4aee3f66 docs(internal): добавлены план отладки ETL и описание багов bookings-db
- Зачем:
  - зафиксировать найденные проблемы demodb и план системной проверки эталона
    до начала подготовки ветки main для студентов.
- Что:
  - добавлен qa-plan.md — 6 блоков проверки пайплайна (чистый прогон,
    идемпотентность, инкремент, многодневный, статика, граничные случаи).
  - добавлен bookings_db_issues.md — 3 бага demodb (gen.connstr без credentials,
    init_days=1 недостаточно, boarding_passes всегда пустая) и UX-проблема
    с временем генерации.
- Проверка:
  - документы, проверка не требуется.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:04:00 +03:00
ddadmin a6a3559ab9 refactor(csv): удален легаси CSV-пайплайн и связанные с ним файлы
- Зачем:
  - Пример базовой загрузки CSV перенесен в отдельный репозиторий `airflow-manual` для разделения учебных треков.
- Что:
  - удалены DAG-файлы `csv_to_greenplum` и вспомогательные скрипты `helpers/greenplum.py`, `orders_ddl.sql`.
  - из `docker-compose.yml` и `.env.example` удалены переменные и тома (`airflow_data`), необходимые для CSV.
  - очищена документация (`README.md`, `TESTING.md`, `educational-tasks.md`) и тесты (`test_dags_smoke.py`, `conftest.py`).
  - отмечен выполненным 'Этап 1' в `TODO.md`.
- Проверка:
  - `make test` проходит успешно (smoke-тесты оставшихся DAG-ов не затронуты).
2026-03-08 19:13:19 +03:00
ddadminandClaude Opus 4.6 45c1d37da5 docs(internal): вынесен план работ из PRD в TODO.md
- Зачем:
  - PRD не должен быть трекером задач, у каждого документа своя роль
- Что:
  - TODO.md реструктурирован: добавлен план подготовки курсовой (5 этапов с рекомендациями по инструментам), выполненные задачи перенесены в отдельную секцию
  - PRD.md: раздел «План работ» заменён ссылкой на TODO.md, убраны решённые вопросы
- Проверка:
  - просмотр TODO.md и docs/internal/PRD.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:48:13 +03:00
ddadminandClaude Opus 4.6 df8c2c36fa docs(internal): разделён PRD на стратегию и дизайн задания
- Зачем:
  - PRD стал перегруженным — смешаны стратегические и тактические решения
- Что:
  - PRD.md сокращён до стратегии (видение, аудитория, скоуп, критерии, риски)
  - создан assignment_design.md (эталонный срез, порядок выполнения, SCD2-подход, валидационный DAG, формат ТЗ)
  - зафиксированы решения: эталон sales_report, валидационный DAG, SCD2 как задание с подсказками
- Проверка:
  - просмотр файлов docs/internal/PRD.md и docs/internal/assignment_design.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:35:24 +03:00
ddadmin d77da5686c docs(internal): добавлена первая версия PRD для DWH-стенда
- Зачем:
  - необходим единый документ с требованиями для разработки ETL-процессов.
- Что:
  - создан файл docs/internal/PRD.md с описанием архитектуры, требований к данным и сроков.
- Проверка:
  - просмотр файла docs/internal/PRD.md.
2026-03-08 18:29:04 +03:00
ddadmin f26ed7875c docs(internal): обновлен статус задач в архитектурном обзоре
- Зачем:
  - необходимо синхронизировать документацию с фактически выполненными изменениями в коде.
- Что:
  - отмечена как выполненная задача по явному указанию storage type для всех таблиц.
  - обновлено обоснование для dds.dim_calendar (AO Row из-за малой ширины таблицы).
  - отмечен как выполненный рефакторинг hashdiff в dim_routes_load.sql.
- Проверка:
  - визуальная сверка docs/internal/architecture_review.md с файлами в sql/dds/ и sql/ods/.
2026-03-05 23:20:02 +03:00
ddadmin 583d832511 docs(airflow): обновлено руководство по программному тестированию DAG
- Зачем:
  - старая инструкция была неполной (без ODS/DDS/DM) и содержала избыточные требования.
- Что:
  - переписан docs/agent-dag-testing.md с фокусом на итеративную разработку и Airflow REST API.
  - добавлены шаги по отладке упавших задач (логгирование через CLI) и проверке DWH слоев.
- Проверка:
  - визуальная проверка текста руководства на соответствие актуальному пайплайну.
2026-03-05 23:06:11 +03:00
ddadmin d89434c3a8 feat(dds): денормализовано измерение dim_routes и упрощена витрина route_performance
- Зачем:
  - улучшение производительности аналитических запросов и упрощение витрин согласно принципам Kimball Star Schema.
- Что:
  - в dds.dim_routes добавлены денормализованные поля городов и моделей самолетов.
  - в скрипт загрузки dim_routes_load.sql добавлена фаза refresh для актуализации атрибутов.
  - загрузка dm.route_performance упрощена до 1 JOIN к измерению маршрутов.
  - обновлен DAG bookings_to_gp_dds и smoke-тесты структуры графа.
  - в документации (db_schema.md) отражены денормализация и lineage версий.
  - в dim_routes_load.sql исправлено затирание _load_id при refresh исторических версий.
- Проверка:
  - make test (smoke-тесты структуры DAG проходят успешно).
2026-03-05 23:05:59 +03:00
ddadmin 68654181d4 docs(internal): добавлен план денормализации dim_routes
- Зачем:
  - необходимо упростить витрину dm.route_performance с 4-JOIN до 1-JOIN;
  - привести измерение в соответствие с принципом Кимбалла («самодостаточное измерение»).
- Что:
  - добавлена задача в TODO.md с описанием цели денормализации;
  - создан детальный план реализации в docs/internal/dim_routes_denormalization_plan.md.
- Проверка:
  - просмотр файлов TODO.md и docs/internal/dim_routes_denormalization_plan.md.
2026-03-04 23:06:45 +03:00
ddadmin 2228312b55 feat(dm): завершено построение DM-слоя (5 витрин) и исправлены баги денормализации
- Зачем:
  - предоставить студентам полный набор аналитических витрин с примерами различных паттернов (UPSERT, Full Rebuild, UNION ALL, двухуровневая агрегация).
- Что:
  - реализованы витрины: sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview.
  - исправлен баг в route_performance_load.sql: добавлены JOIN к dim_airports и dim_airplanes для корректной денормализации атрибутов.
  - обновлен скрипт e2e_etl.sh: добавлена верификация всех 5 витрин и проверка бизнес-логики (load factor).
  - обновлены DAGи, тесты и главный DDL скрипт.
- Проверка:
  - автоматизированный прогон e2e_etl.sh через REST API Airflow.
2026-03-04 22:55:16 +03:00
ddadmin f68b055b00 feat(dm): добавлена витрина airport_traffic и внедрен паттерн dual-role dimensions
- Зачем:
  - необходимо продемонстрировать студентам работу с одной сущностью в разных ролях (вылет/прилет) через UNION ALL.
- Что:
  - созданы DDL, Load и DQ скрипты для витрины dm.airport_traffic (пассажиропоток аэропортов).
  - реализован паттерн Unpivot через UNION ALL для консолидации метрик вылета и прилета в одном разрезе.
  - внедрена инкрементальная загрузка по затронутым датам (HWM) с честным подсчетом рейсов через COUNT(DISTINCT).
  - добавлены подробные комментарии к колонкам выручки, предупреждающие о риске двойного счета.
  - обновлены DAGи bookings_dm_ddl и bookings_to_gp_dm для включения витрины в общий пайплайн.
- Проверка:
  - визуальный аудит SQL-кода на предмет использования airport_bk и корректной агрегации по ролям.
  - наличие DQ-инварианта total_passengers = departures + arrivals.
  - верификация блока UPDATE: теперь обновляются и денормализованные атрибуты (city, airport_bk).
2026-03-04 22:19:06 +03:00
ddadmin a06b52c38d feat(dm): добавлена витрина passenger_loyalty и внедрен паттерн HWM по ключу
- Зачем:
  - необходимо продемонстрировать студентам метод инкрементального пересчета "затронутых ключей" для больших справочных витрин.
- Что:
  - созданы DDL, Load и DQ скрипты для витрины dm.passenger_loyalty (лояльность пассажиров).
  - реализован расчет моды (самый частый тариф) через PostgreSQL-специфику DISTINCT ON.
  - внедрена корректная агрегация SCD2-измерений (unique_routes) по бизнес-ключу route_bk.
  - настроено Heap-хранилище (WITH appendonly=false) для эффективного выполнения UPSERT.
  - обновлены DAGи bookings_dm_ddl и bookings_to_gp_dm для включения новой витрины в конвейер.
- Проверка:
  - визуальный аудит SQL на предмет использования p.passenger_id (BK) и r.route_bk.
  - наличие учебной DQ-проверки ссылочной целостности и инварианта дат (first <= last).
  - проверка параллельности задач в Airflow DAG.
2026-03-04 22:03:26 +03:00
ddadmin 491f1e68d6 feat(dm): добавлена витрина route_performance и обновлены DM DAGи
- Зачем:
  - необходимо продемонстрировать студентам альтернативный паттерн загрузки (Full Rebuild) и использование AO Column Store в Greenplum.
- Что:
  - созданы DDL, Load и DQ скрипты для витрины dm.route_performance (эффективность маршрутов).
  - реализована агрегация по бизнес-ключу route_bk для корректной обработки SCD2-измерений.
  - настроен формат хранения AO Column Store с компрессией zstd (уровень 1).
  - обновлены DAGи bookings_dm_ddl и bookings_to_gp_dm для параллельной оркестрации новой витрины.
- Проверка:
  - визуальный аудит SQL-кода на соответствие naming_conventions.md.
  - проверка структуры DAG в Airflow (параллельные ветки load -> dq).
  - наличие бизнес-инварианта total_boarded <= total_tickets в DQ-скрипте.
2026-03-04 21:52:10 +03:00
ddadmin 22a0590e1b feat(sql): явное управление storage и автоматизированный E2E-тест через REST API
- Зачем:
  - необходимо визуализировать выбор типа хранения (Heap vs Append-Only) для учебных целей.
  - автоматизировать проверку всей цепочки DWH для исключения ручных ошибок.
  - сделать процесс отладки прозрачным и наглядным через стандартные инструменты Airflow.
- Что:
  - внедрена клауза WITH (appendonly=...) во все DDL; ODS-справочники переведены на AO Row и TRUNCATE+INSERT.
  - создан скрипт scripts/e2e_etl.sh для полного прогона ETL (DDL + 2 дня данных) через REST API.
  - исправлены баги типизации (INTEGER[]), именования полей (amount, passenger_id) и удалены фантомные колонки (contact_data).
  - обновлен e2e-etl-test-protocol.md: добавлен раздел по отладке, чтению логов и перезапуску задач через API.
  - исправлены pytest-контракты под новую логику загрузки.
- Проверка:
  - успешный прогон `make e2e-smoke` (полный цикл от очистки до витрины).
2026-03-03 22:38:26 +03:00
ddadmin ab97d6a535 chore(todo): добавлены задачи по REST API и устойчивости bookings-db
- Зачем:
  - зафиксировать новые приоритеты по тестированию ETL и стабильности источника.
- Что:
  - добавлены два новых пункта в backlog мейнтейнеров в TODO.md.
  - уточнён статус файла, чтобы он отражал наличие открытых задач.
- Проверка:
  - git diff --cached -- TODO.md.
2026-03-02 22:41:34 +03:00
ddadmin dd6274b948 chore(todo): добавлены задачи по REST API и устойчивости bookings-db
- Зачем:
  - зафиксировать новые приоритеты по тестированию ETL и стабильности источника.
- Что:
  - добавлены два новых пункта в backlog мейнтейнеров в TODO.md.
  - уточнён статус файла, чтобы он отражал наличие открытых задач.
- Проверка:
  - git diff --cached -- TODO.md.
2026-03-02 22:40:31 +03:00
ddadmin 4d793a9c11 refactor(sql): заменен тип сжатия zlib на zstd для AO-таблиц
- Зачем:
  - zstd (level 1) является современным стандартом для Greenplum 6.0+, обеспечивая более высокую скорость декомпрессии и лучшее сжатие.
- Что:
  - обновлены все DDL стейджинга (STG) и базовых таблиц.
  - обновлена архитектурная документация (ADR-3) и планы реализации.
  - исправлены примеры кода в Airflow DAG и описании ETL.
- Проверка:
  - успешное выполнение CREATE TABLE с новыми параметрами в Greenplum 6.27.1.
2026-03-01 20:26:56 +03:00
ddadmin 92f3828a45 test(e2e): протокол сквозного тестирования и инструменты очистки DWH
- Зачем:
  - формализация проверки всей цепочки ETL (STG -> ODS -> DDS -> DM).
- Что:
  - создан docs/e2e-etl-test-protocol.md и sql/truncate_gp.sql.
  - в Makefile добавлена команда dwh-truncate.
  - start_date во всех DAG изменен на 2017-01-01.
- Проверка:
  - выполнение make dwh-truncate и прогон DAG.
2026-03-01 20:08:01 +03:00
ddadmin 2937185cfa fix(ods): внедрен паттерн Temporary Table для транзакционных загрузок
- Зачем:
  - исправление критических ошибок P0 (гонка HWM, ошибки в SQL CTE, непоследовательный lineage).
  - использование временных таблиц делает код более читаемым для студентов и производительным для Greenplum.
- Что:
  - в sql/ods/ (bookings, tickets, segments, boarding_passes, flights) выборка дельты вынесена в CREATE TEMP TABLE.
  - HWM теперь вычисляется один раз, устраняя гонку между UPDATE и INSERT.
  - во всех стейтментах используется оригинальный batch_id из STG для _load_id.
  - поле _load_ts в ODS теперь берется из STG (load_dttm), что делает HWM-сравнение корректным.
- Проверка:
  - визуальный аудит SQL-логики.
2026-03-01 18:55:00 +03:00
ddadmin daeae7d612 docs(review): отмечен исправленным баг с потерей данных в ODS
- Зачем:
  - необходимо зафиксировать выполнение критической задачи (P0) в плане работ.
- Что:
  - в архитектурном ревью пункт "ODS batch resolver теряет данные" отмечен как выполненный.
- Проверка:
  - визуальная проверка docs/internal/architecture_review.md.
2026-03-01 18:39:03 +03:00
ddadmin 483ed30882 docs(review): ADR-1, ADR-2, ADR-3 — архитектурные решения
- Зачем:
  - зафиксировать принятые архитектурные решения с обоснованием для менти,
    чтобы студенты понимали границы учебных упрощений и реальную практику.
- Что:
  - ADR-1: города/страны/модели — атрибуты измерений (star vs snowflake).
  - ADR-2: партиционирование не используем (учебные объёмы), с примером
    exchange partition и контекстом импортозамещения (Teradata/Exadata → GP).
  - ADR-3: явный storage type для каждой таблицы — AO Column для write-once,
    AO Row для snapshot-справочников (TRUNCATE+INSERT), heap для UPDATE-таблиц.
  - добавлена задача P2 по реализации ADR-3, обновлена сводка трудозатрат.
- Проверка:
  - cat docs/internal/architecture_review.md | grep "ADR-".
2026-03-01 18:34:18 +03:00
ddadmin cfd20328d6 fix(ods): изменен батчевый резолвер для транзакционных таблиц
- Зачем:
  - текущая реализация ODS batch_id теряла данные транзакционных таблиц, если между запусками ODS STG успевал отработать дважды (брался только последний батч).
- Что:
  - изменены скрипты загрузки транзакционных таблиц (bookings, tickets, flights, segments, boarding_passes) для использования паттерна HWM по _load_ts вместо фильтрации по конкретному батчу.
  - обновлен комментарий в DAG bookings_to_gp_ods, объясняющий разное поведение для справочников и транзакционных данных.
  - сохранено использование оригинального batch_id из STG для поля _load_id в слое ODS для сквозного трассирования.
- Проверка:
  - запуск пайплайнов и проверка, что все батчи загружаются из STG в ODS без потерь.
2026-03-01 18:15:04 +03:00
ddadmin faf410b4e8 docs(review): добавлен P0-баг — потеря данных при двойном STG-запуске
- Зачем:
  - обнаружена проблема: ODS batch resolver берёт только последний batch,
    транзакционные данные промежуточных STG-запусков теряются навсегда.
- Что:
  - добавлен P0 в architecture_review.md с описанием сценария и решения.
  - обновлена сводная таблица трудозатрат.
- Проверка:
  - cat docs/internal/architecture_review.md | grep -A5 "batch resolver теряет".
2026-03-01 17:52:59 +03:00
ddadmin 768334453f docs(dds): добавлены комментарии и исправлены ошибки в DWH
- Зачем:
  - закрыты задачи P0 и P1 из ревью архитектуры для повышения понятности стенда для студентов.
- Что:
  - исправлен distribution key для airport_traffic в дизайн-документе.
  - добавлены комментарии о генерации SK и отсутствии SK в фактах.
  - создан документ docs/dag_execution_order.md с описанием порядка запуска DAG-ов.
  - объяснена логика late-arriving dimensions и batch resolver.
  - добавлена legacy-пометка для хелпера greenplum.py.
- Проверка:
  - визуальная проверка добавленных комментариев и новых файлов.
2026-03-01 17:50:53 +03:00
ddadmin e29a249830 docs(dds): добавлены учебные комментарии о генерации SK
- Зачем:
  - объяснить студентам, почему подход MAX() + ROW_NUMBER() безопасен только при последовательном запуске.
- Что:
  - добавлен комментарий в dim_airplanes_load.sql.
  - добавлен комментарий в dim_airports_load.sql.
  - добавлен комментарий в dim_passengers_load.sql.
  - добавлен комментарий в dim_routes_load.sql.
  - добавлен комментарий в dim_tariffs_load.sql.
- Проверка:
  - git diff HEAD~1 sql/dds/.
2026-03-01 17:42:42 +03:00
ddadmin 8870ba9088 docs(review): добавлено ревью архитектуры слоёв DWH
- Зачем:
  - зафиксировать оценку учебной ценности текущей конструкции (STG→ODS→DDS→DM)
    и сформировать backlog задач для доработки стенда.
- Что:
  - создан docs/internal/architecture_review.md с анализом 9 сильных сторон
    и приоритизированным списком замечаний (P0–P3) с чеклистом задач.
- Проверка:
  - cat docs/internal/architecture_review.md.
2026-03-01 17:36:56 +03:00
ddadmin f2f0468cb1 docs(dm): описание паттернов HWM и TEMP TABLE в дизайн-документе
- Зачем:
  - дизайн-документ описывал устаревший batch-driven подход и не фиксировал паттерн TEMP TABLE, используемый в реализации.
- Что:
  - заменено описание загрузки всех UPSERT-витрин на HWM-инкрементальность через _load_ts.
  - добавлена секция «Общий паттерн загрузки UPSERT-витрин» с SQL-скелетом и таблицей применимости.
  - в секции «Учит» каждой витрины добавлены паттерны HWM и TEMP TABLE.
- Проверка:
  - визуальная проверка docs/internal/bookings_dm_design.md.
2026-03-01 17:09:55 +03:00
ddadmin 81ff396402 refactor(dm): переход sales_report на HWM-инкрементальность через _load_ts
- Зачем:
  - фильтр `_load_id = '{{ run_id }}'` использовал run_id DM-DAG-а, который не совпадает с run_id DDS-DAG-а, записанным в факты — витрина не находила дельту.
- Что:
  - load: заменён _load_id-фильтр на HWM-подзапрос `_load_ts > MAX(_load_ts)` из dm.sales_report.
  - dq: источник затронутых дат переключён с DDS на саму витрину (где _load_id уже корректный).
- Проверка:
  - `make test` — smoke-тесты зелёные.
  - запуск `bookings_to_gp_dm` в Airflow после загрузки DDS.
2026-03-01 17:09:42 +03:00
ddadmin 84ec949ff9 refactor(dm): переход на batch-driven инкрементальность для sales_report
- Зачем:
  - жесткая привязка инкремента к логической дате Airflow ({{ ds }}) приводила к пустой витрине при обработке исторических и "опоздавших" (late-arriving) данных.
- Что:
  - изменена фильтрация в скрипте загрузки витрины: теперь динамически определяются даты, затронутые текущим батчем (через _load_id).
  - обновлены DQ-проверки для валидации только тех дат, которые были изменены в рамках запущенного батча.
  - в дизайн-документ добавлено описание паттерна работы с late-arriving facts для студентов.
- Проверка:
  - запуск пайплайна "с нуля" за логическую дату 2024-01-01 приводит к корректному расчету агрегатов для исторических данных 2017 года (>8000 строк).
2026-03-01 01:54:57 +03:00
ddadmin a8cce6cd6b refactor(dm): исправление архитектуры витрины sales_report
- Зачем:
  - исходная реализация содержала критические ошибки MPP (Load/Processing Skew) и Full Scan.
  - требуется демонстрация студентам эталонного инкрементального UPSERT.
- Что:
  - изменен ключ дистрибуции с flight_date на (departure_airport_sk, arrival_airport_sk).
  - внедрен каноничный UPSERT через TEMP TABLE (агрегация выполняется один раз).
  - добавлен инкрементальный фильтр по {{ ds }} для предотвращения Full Scan dds.fact_flight_sales.
  - проверки DQ переведены в инкрементальный режим (валидация только текущего батча).
- Проверка:
  - airflow dags test bookings_to_gp_dm 2026-02-28.
2026-02-28 23:56:31 +03:00
ddadmin 20a1270633 Merge branch 'main' into chore/bookings-etl 2026-02-28 22:53:38 +03:00
ddadmin c6ac41c3f6 docs: добавлен служебный файл GEMINI.md для контекста агентов
- Зачем:
  - файл позволяет AI-агентам быстро находить и использовать контекст из AGENTS.md
- Что:
  - создан файл GEMINI.md с ссылкой на @AGENTS.md
- Проверка:
  - cat GEMINI.md
2026-02-28 22:52:49 +03:00
ddadmin 016cc3d43d Merge remote-tracking branch 'origin/chore/bookings-etl' into chore/bookings-etl
# Conflicts:
#	docs/internal/bookings_ods_design.md
#	sql/ddl_gp.sql
#	sql/ods/airplanes_load.sql
#	sql/ods/airports_load.sql
#	tests/test_dags_smoke.py
#	tests/test_ods_snapshot_integration.py
2026-02-28 22:20:17 +03:00
ddadmin 5efb833837 feat(sql): нормализация JSON-полей в ODS для airports и airplanes
- Зачем:
  - источник хранит мультиязычные названия как JSON ({"en": "...", "ru": "..."}),
  - для упрощения downstream-логики (DDS/DM) нужны чистые строки на одном языке.
- Что:
  - добавлен парсинг JSON с извлечением поля 'ru' в sql/ods/airports_load.sql
    (airport_name, city, country).
  - добавлен парсинг JSON с извлечением поля 'ru' в sql/ods/airplanes_load.sql
    (model).
  - обновлена документация docs/internal/bookings_ods_design.md с примечаниями
    о нормализации.
  - обновлены тестовые данные в tests/test_ods_snapshot_integration.py для
    соответствия JSON-формату STG.
- Проверка:
  - uv run make test (15 passed).
  - SELECT airport_name FROM ods.airports → "Аль-Баха" (вместо JSON).
2026-02-28 22:13:11 +03:00
ddadmin 7d18b3fe5c feat(dm): добавлена эталонная витрина dm.sales_report
- Зачем:
  - требуется эталонная витрина для обучения паттернам DM-слоя
  - демонстрация UPSERT-логики с IS DISTINCT FROM для идемпотентности
- Что:
  - DDL: heap-таблица dm.sales_report с 18 полями, DISTRIBUTED BY (flight_date)
  - Load: UPSERT (UPDATE + INSERT) с JOIN dim_calendar, dim_airports (x2), dim_tariffs, fact_flight_sales
  - DQ: PL/pgSQL DO $$ с проверками непустоты, уникальности, tickets_sold >= passengers_boarded, boarding_rate BETWEEN 0 AND 1
  - DAGs: bookings_dm_ddl (DDL), bookings_to_gp_dm (ETL + DQ с параллельными ветками)
  - Tests: smoke-тесты для обоих DAG
  - sql/ddl_gp.sql: добавлен \i dm/sales_report_ddl.sql
- Проверка:
  - make fmt && make lint — passed
  - make test — 15 passed, 11 skipped
  - make ddl-gp — DDL applied successfully
  - airflow dags test bookings_to_gp_dm 2026-02-28T13:00:00 — 4 tasks SUCCESS
  - 9243 rows loaded, _load_id подставлен корректно (Jinja2 templating works)
  - DQ checks passed
2026-02-28 22:13:11 +03:00
ddadminandClaude Opus 4.6 e577f31a67 docs(dm): добавлен дизайн-документ DM-слоя
- Зачем:
  - зафиксировать архитектуру 5 витрин DM перед реализацией и отдать на ревью.
- Что:
  - создан docs/internal/bookings_dm_design.md с описанием всех витрин, DAG-структуры, DQ-проверок и порядка реализации.
- Проверка:
  - cat docs/internal/bookings_dm_design.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:13:11 +03:00
ddadmin b6b87a7a00 fix(ods): исправлена загрузка flights для ссылок из segments
- Зачем:
  - dq_ods_segments падал на непустых батчах из-за orphan flight_id в ods.segments.
- Что:
  - доработан sql/ods/flights_load.sql: добавлено добирание рейсов из истории stg.flights для flight_id из stg.segments текущего batch.
  - добавлен контрактный тест в tests/test_ods_sql_contract.py на покрытие flight_id из segments.
  - обновлена документация DAG в docs/bookings_to_gp_ods.md.
- Проверка:
  - make test.
  - airflow dags trigger bookings_to_gp_ods -c '{"stg_batch_id":"manual__2026-01-18T18:47:18.316091+00:00"}'.
2026-02-28 22:13:11 +03:00
ddadmin e592b35461 feat(dds): реализован слой dds для bookings
- Зачем:
  - подготовлен учебный Star Schema слой для перехода от ODS к аналитике и витринам.
- Что:
  - добавлены 21 SQL-файл для DDS (DDL/LOAD/DQ) с SCD1/SCD2 и фактом `fact_flight_sales`.
  - добавлены DAG `bookings_dds_ddl` и `bookings_to_gp_dds`, а также smoke-тесты структуры DAG.
  - обновлены `sql/ddl_gp.sql` и документация (`README`, `docs/*`, `db_schema`) под поток `stg -> ods -> dds`.
- Проверка:
  - make test.
2026-02-28 22:13:11 +03:00
ddadmin fc798aadd1 docs(dwh): актуализирована db_schema для ODS и DDS
- Зачем:
  - синхронизировать db_schema.md с фактическим состоянием репозитория и текущим DDS-планом.
- Что:
  - обновлён статус слоёв: STG/ODS отмечены как реализованные, DDS как слой в проектировании.
  - приведены к актуальному виду DDS-объекты и нейминг (`dds.dim_*`, `dds.fact_flight_sales`, `dds.dim_routes` SCD2).
  - обновлены mermaid-диаграмма, обучающие пояснения, TODO, связанные документы и история изменений.
- Проверка:
  - git diff -- docs/internal/db_schema.md
2026-02-28 22:13:11 +03:00
ddadmin 0dfecf4cd8 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
2026-02-28 22:13:11 +03:00
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
ddadminandClaude Opus 4.6 6f8dd9c7c7 docs(dwh): добавлен план реализации DDS слоя (Star Schema)
- Зачем:
  - зафиксировать архитектурные решения DDS до начала реализации (для Codex).
- Что:
  - создан docs/internal/bookings_dds_design.md с полным планом DDS.
  - 6 измерений (calendar, airports, airplanes, tariffs, passengers, routes) + факт flight_sales.
  - dim_routes реализует классический SCD2 с hashdiff.
  - все остальные измерения — SCD1 UPSERT со стабильными SK.
  - факт — инкрементальный UPSERT по зерну (ticket_no, flight_id).
- Проверка:
  - 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
ddadmin 85fea19818 feat(ods): реализован ODS слой и DAG загрузки из STG
- Зачем:
  - подготовлена учебная реализация ODS слоя с типизацией, UPSERT и DQ, чтобы продолжить работу от STG к DDS/DM.
- Что:
  - добавлены SQL-скрипты `sql/ods/*_ddl.sql`, `sql/ods/*_load.sql`, `sql/ods/*_dq.sql` для 9 сущностей bookings.
  - добавлены DAG `bookings_ods_ddl` и `bookings_to_gp_ods`, а также smoke-тесты для новых графов.
  - ODS DDL интегрирован в `sql/ddl_gp.sql`; документация и план обновлены под единый запуск через `make ddl-gp`.
- Проверка:
  - `make test`.
  - `make ddl-gp`.
2026-02-28 22:13:11 +03:00
ddadminandClaude Opus 4.6 2d629b6e80 fix(dwh): исправлены 3 замечания codex-ревью плана ODS
- Зачем:
  - codex нашёл баги, которые сломали бы первый прогон ODS.
- Что:
  - resolve batch: MAX(batch_id) заменён на ORDER BY load_dttm DESC LIMIT 1
    (run_id — строка, лексикографический MAX не гарантирует хронологию).
  - ручная проверка: добавлен make ddl-gp-ods (без него ODS-объекты не создадутся).
  - routes/flights FK: расширено пояснение, почему проверка по route_no без
    validity допустима в ODS и что учитывать при join в DDS.
- Проверка:
  - make test (11 passed, 4 skipped).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:13:06 +03:00
ddadminandClaude Opus 4.6 105c4ac31c docs(dwh): исправлен план ODS после ревью (v2 → v3)
- Зачем:
  - v2 содержал баги (CTE scope, distribution key), неточности маппинга
    колонок и пробелы в описании DAG-контракта, что сломало бы реализацию.
- Что:
  - исправлен CTE scope: дублирование CTE в UPDATE и INSERT statements.
  - ods.tickets: DISTRIBUTED BY (ticket_no) вместо book_ref для UPSERT.
  - добавлен Python-сниппет resolve_stg_batch_id (PythonOperator + XCom).
  - заменены {{ params.stg_batch_id }} → {{ ti.xcom_pull(...) }} во всех SQL.
  - <> заменён на IS DISTINCT FROM (NULL-safe change detection).
  - DISTINCT ON заменён на ROW_NUMBER() (стандартный DE-паттерн).
  - добавлена секция 4.3 с полной таблицей маппинга STG → ODS.
  - routes: scheduled_departure_time → departure_time, duration без rename.
  - DAG-граф: resolve_stg_batch_id как корневой таск, параллельные ветки.
  - DQ: добавлена проверка «батч не пустой» для snapshot-справочников.
  - DoD: убрано valid_from/valid_to, оставлено _load_id/_load_ts/event_ts.
- Проверка:
  - make test (11 passed, 4 skipped).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:13:06 +03:00
ddadmin 9b1e853d99 docs(dwh): зафиксированы конвенции нейминга и обновлён план ODS
- Зачем:
  - нужен единый стандарт именования полей, чтобы новые слои не расходились с учебными материалами.
- Что:
  - добавлен единый документ с правилами нейминга `docs/internal/naming_conventions.md`.
  - полностью переписан `docs/internal/bookings_ods_design.md` в эталонный учебный план ODS (SCD1, батч-контракт, DQ, граф DAG).
  - добавлены ссылки на стандарт нейминга в `docs/README.md`, `docs/internal/db_schema.md` и `AGENTS.md`.
- Проверка:
  - проверен diff по измененным файлам (`git diff`).
2026-02-28 22:13:06 +03:00
ddadmin 9e5ce91bcc docs(internal): добавлен план реализации ODS слоя
- Зачем:
  - зафиксировать архитектурное решение для ODS слоя перед началом разработки.
- Что:
  - создан файл docs/internal/bookings_ods_design.md.
  - описана архитектура ODS и отличия от STG слоя.
  - добавлены схемы таблиц (справочники с SCD Type 2, транзакции с UPSERT).
- Проверка:
  - git show --stat HEAD
2026-02-28 22:13:06 +03:00
ddadmin 5dfd9d3368 docs(testing): добавлены проверки source-данных перед DAG-тестами
- Зачем:
  - исключены ложные падения DQ при пустом source в bookings-db.
- Что:
  - в TESTING.md добавлены pre-check команды COUNT(*) перед Trigger DAG.
  - в docs/agent-dag-testing.md добавлена обязательная проверка source и fallback при COUNT(*) = 0.
  - в таблицу типичных проблем добавлен кейс падения check_airports_dq/check_airplanes_dq с ошибкой "..._ext нет строк".
- Проверка:
  - rg -n "source непустой|COUNT\(\*\) = 0|bookings-generate-day|check_airports_dq" TESTING.md docs/agent-dag-testing.md
2026-02-28 22:13:06 +03:00
ddadminandClaude Sonnet 4.6 9f8098edd5 docs(agents): добавлен гайд по программному тестированию DAG
- Зачем:
  - AI-агенты пытались тестировать DAG через браузер вместо CLI/API,
    так как не было явных инструкций по программному подходу.
- Что:
  - добавлен docs/agent-dag-testing.md: CLI, REST API Airflow,
    проверка параллельности, запросы в Greenplum, E2E-тест, шпаргалка команд.
  - в AGENTS.md добавлена ссылка на новый гайд в раздел «Тестирование».
  - из docs/README.md убрана ссылка (файл для людей, не для агентов).
- Проверка:
  - cat docs/agent-dag-testing.md && grep agent-dag-testing AGENTS.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:13:06 +03:00
ddadmin 2c80955eb6 docs: добавлен CLAUDE.md с инструкциями для AI-ассистента
- Зачем:
  - необходимо зафиксировать контекст и правила работы с проектом для Claude AI.
- Что:
  - создан файл CLAUDE.md с описанием структуры проекта.
  - добавлены инструкции по коммитам и стилю кода.
- Проверка:
  - git show --stat HEAD
2026-02-28 22:12:22 +03:00
ddadminandClaude Opus 4.6 39ff430ed0 refactor(dags): параллелизован граф DAG bookings_to_gp_stage
- Зачем:
  - линейный граф маскировал реальные зависимости данных; для эталонного
    стенда важно показать менти параллельный граф там, где данные независимы.
- Что:
  - airports и airplanes грузятся параллельно после check_tickets_dq.
  - routes ждёт обоих (DQ проверяет ссылочную целостность на оба справочника).
  - seats зависит только от airplanes и работает параллельно с веткой
    routes → flights → segments → boarding_passes.
  - finish_summary ждёт обе ветки (check_boarding_passes_dq + check_seats_dq).
  - datetime.utcnow() заменён на datetime.now(UTC) в csv_to_greenplum.py.
  - smoke-тесты дополнены проверкой параллельности и второй ветки.
  - документация обновлена с ASCII-схемой нового графа.
- Проверка:
  - make test (11 passed, 4 skipped), make lint — чисто.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:12:22 +03:00
ddadmin 3dcaeb85ce Доводка стилистики документации 2026-02-28 22:12:22 +03:00
ddadmin 159ee99a32 Улучшение тестов 2026-02-28 22:12:22 +03:00
ddadmin 6ce7915806 Улучшение DQ проверки 2026-02-28 22:12:22 +03:00
ddadmin 31dde678c5 Улучшения комментариев 2026-02-28 22:12:22 +03:00
ddadmin 16e8a883b8 статусы задач 2026-02-28 22:12:22 +03:00
ddadmin d4c91c4eec Допустимость пустых данных по некоторым таблицам 2026-02-28 22:12:22 +03:00
ddadmin bb83c831d2 Исправлены критические замечания 2026-02-28 22:12:22 +03:00
ddadmin 4b119fbd7c Ревью решения 2026-02-28 22:12:22 +03:00
ddadmin 7312cdfb68 Отладка потоков 2026-02-28 22:12:22 +03:00
ddadmin 7abeb67fba Генерация dds слоя по ТЗ - без тестов 2026-02-28 22:12:22 +03:00
ddadmin 9b8a6f3ce4 Замечания от codex 2026-02-28 22:12:22 +03:00
ddadmin 733f14eee3 план работ by glm 2026-02-28 22:12:22 +03:00
ddadmin ece3873f54 Финальные доработки 2026-02-28 22:12:22 +03:00
ddadmin 1812ced796 Доработки GLM 2026-02-28 22:12:22 +03:00
ddadmin 52031c75e6 доработки плана 2026-02-28 22:12:22 +03:00
ddadmin 941601a257 план витрин до stg слоя 2026-02-28 22:12:22 +03:00
ddadmin d6fed5c7b9 Полировка кода 2026-02-28 22:12:22 +03:00
ddadmin 33110eed48 Проверка/рецензирование доработки 2026-02-28 22:12:22 +03:00
ddadmin 256d594ac8 актуализация "что делали" 2026-02-28 22:11:44 +03:00
ddadmin ad663efcea Отладка 2026-02-28 22:11:44 +03:00
ddadmin 897a76588d Первая версия dag 2026-02-28 22:11:44 +03:00
ddadmin c23bbed10c План работ 2026-02-28 22:11:44 +03:00
ddadmin ffd9585ef4 fix(bookings): добавлено явное ограничение генератора на jobs=1
- Зачем:
  - исключить нестабильные режимы генератора, в которых bookings может перестать пополняться.
  - сделать причину ошибки понятной студенту сразу при запуске команд и SQL-скриптов.
- Что:
  - добавлен precheck `bookings-check-jobs` в Makefile и подключён к `bookings-init` и `bookings-generate-day`.
  - добавлены явные проверки `bookings.jobs = 1` в `bookings/generate_next_day.sql` и `sql/src/bookings_generate_day_if_missing.sql`.
  - обновлена документация и `.env.example`: зафиксировано, что в стенде поддерживается только `BOOKINGS_JOBS=1`.
- Проверка:
  - uv run make fmt.
  - uv run make test.
  - env BOOKINGS_JOBS=2 make bookings-init.
  - env BOOKINGS_JOBS=2 make bookings-generate-day.
  - make bookings-init.
  - make bookings-generate-day.
2026-02-28 22:00:27 +03:00
ddadmin a10cbff906 fix(bookings): откатаны ошибочные изменения в инициализации demodb
- Зачем:
  - предыдущий фикс ломал make bookings-init из-за невалидного SQL в Makefile.
- Что:
  - удалены SQL-блоки постобработки reference-данных и cume_dist из bookings-init.
  - удалён случайно добавленный gitlink _vendor/demodb.
  - удалён SET LOCAL application_name из generate_next_day.sql.
- Проверка:
  - uv run make fmt.
  - uv run make test.
  - make bookings-init.
  - make bookings-generate-day.
2026-02-28 19:54:57 +03:00
ddadmin 09c940ea0e fix(bookings): исправлен бесконечный цикл busy() и генерации имён пассажиров
- Зачем:
  - генератор demodb зависал при jobs=1 из-за самоблокировки busy()
  - get_passenger_name() попадал в бесконечный цикл из-за NULL в cume_dist
- Что:
  - добавлен SET LOCAL application_name = 'psql' после вызова generate/continue
  - добавлена загрузка firstnames.dat и lastnames.dat в Makefile
  - добавлено заполнение cume_dist для firstnames, lastnames и airport_to_prob
- Проверка:
  - make bookings-generate-day выполняется успешно, создаётся ~45K бронирований
2026-02-28 19:36:48 +03:00
ddadmin 25f04fd0f0 Merge branch 'main' into chore/bookings-etl 2026-02-28 14:54:53 +03:00
ddadmin acdbb8be3e docs(agents): реструктуризация инструкций, добавлено правило баланса сложности
- Зачем:
  - документ разросся и стал неструктурированным; нужно выделить ключевое правило про баланс production-ready и KISS для начинающих Data инженеров.
- Что:
  - реструктуризация: введена нумерация разделов (1-6), удалены дублирующиеся детали команд и стиля кода.
  - добавлен раздел 1 "Главное правило генерации кода (Баланс)" с принципами Production-ready, KISS и Фокус на «Почему».
  - расширена карта проекта: добавлены все слои DWH (src/, stg/, ods/, dds/, dm/) и naming conventions.
  - добавлен раздел 6 "Порядок работы Агента" с чек-листом валидации.
- Проверка:
  - cat AGENTS.md && git log -1
2026-02-28 14:53:09 +03:00
ddadminandClaude Opus 4.6 e986222d46 docs(dm): добавлен дизайн-документ DM-слоя
- Зачем:
  - зафиксировать архитектуру 5 витрин DM перед реализацией и отдать на ревью.
- Что:
  - создан docs/internal/bookings_dm_design.md с описанием всех витрин, DAG-структуры, DQ-проверок и порядка реализации.
- Проверка:
  - cat docs/internal/bookings_dm_design.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:37:26 +03:00
ddadmin 8fd5ae1069 fix(ods): исправлена загрузка flights для ссылок из segments
- Зачем:
  - dq_ods_segments падал на непустых батчах из-за orphan flight_id в ods.segments.
- Что:
  - доработан sql/ods/flights_load.sql: добавлено добирание рейсов из истории stg.flights для flight_id из stg.segments текущего batch.
  - добавлен контрактный тест в tests/test_ods_sql_contract.py на покрытие flight_id из segments.
  - обновлена документация DAG в docs/bookings_to_gp_ods.md.
- Проверка:
  - make test.
  - airflow dags trigger bookings_to_gp_ods -c '{"stg_batch_id":"manual__2026-01-18T18:47:18.316091+00:00"}'.
2026-02-25 23:12:27 +03:00
ddadmin d25c753eb4 feat(dds): реализован слой dds для bookings
- Зачем:
  - подготовлен учебный Star Schema слой для перехода от ODS к аналитике и витринам.
- Что:
  - добавлены 21 SQL-файл для DDS (DDL/LOAD/DQ) с SCD1/SCD2 и фактом `fact_flight_sales`.
  - добавлены DAG `bookings_dds_ddl` и `bookings_to_gp_dds`, а также smoke-тесты структуры DAG.
  - обновлены `sql/ddl_gp.sql` и документация (`README`, `docs/*`, `db_schema`) под поток `stg -> ods -> dds`.
- Проверка:
  - make test.
2026-02-25 23:05:43 +03:00
ddadmin 9dbfc3572d docs(dwh): актуализирована db_schema для ODS и DDS
- Зачем:
  - синхронизировать db_schema.md с фактическим состоянием репозитория и текущим DDS-планом.
- Что:
  - обновлён статус слоёв: STG/ODS отмечены как реализованные, DDS как слой в проектировании.
  - приведены к актуальному виду DDS-объекты и нейминг (`dds.dim_*`, `dds.fact_flight_sales`, `dds.dim_routes` SCD2).
  - обновлены mermaid-диаграмма, обучающие пояснения, TODO, связанные документы и история изменений.
- Проверка:
  - git diff -- docs/internal/db_schema.md
2026-02-24 00:09:14 +03:00
ddadmin e98821f28d 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
2026-02-24 00:03:37 +03:00
ddadminandClaude Opus 4.6 06a5b7bc4c 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-23 23:05:16 +03:00
ddadminandClaude Opus 4.6 e6e923794c docs(dwh): добавлен план реализации DDS слоя (Star Schema)
- Зачем:
  - зафиксировать архитектурные решения DDS до начала реализации (для Codex).
- Что:
  - создан docs/internal/bookings_dds_design.md с полным планом DDS.
  - 6 измерений (calendar, airports, airplanes, tariffs, passengers, routes) + факт flight_sales.
  - dim_routes реализует классический SCD2 с hashdiff.
  - все остальные измерения — SCD1 UPSERT со стабильными SK.
  - факт — инкрементальный UPSERT по зерну (ticket_no, flight_id).
- Проверка:
  - cat docs/internal/bookings_dds_design.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:55:07 +03:00
ddadmin 03765d2808 feat(ods): реализован ODS слой и DAG загрузки из STG
- Зачем:
  - подготовлена учебная реализация ODS слоя с типизацией, UPSERT и DQ, чтобы продолжить работу от STG к DDS/DM.
- Что:
  - добавлены SQL-скрипты `sql/ods/*_ddl.sql`, `sql/ods/*_load.sql`, `sql/ods/*_dq.sql` для 9 сущностей bookings.
  - добавлены DAG `bookings_ods_ddl` и `bookings_to_gp_ods`, а также smoke-тесты для новых графов.
  - ODS DDL интегрирован в `sql/ddl_gp.sql`; документация и план обновлены под единый запуск через `make ddl-gp`.
- Проверка:
  - `make test`.
  - `make ddl-gp`.
2026-02-23 01:51:40 +03:00
ddadminandClaude Opus 4.6 dc1dc9c66a fix(dwh): исправлены 3 замечания codex-ревью плана ODS
- Зачем:
  - codex нашёл баги, которые сломали бы первый прогон ODS.
- Что:
  - resolve batch: MAX(batch_id) заменён на ORDER BY load_dttm DESC LIMIT 1
    (run_id — строка, лексикографический MAX не гарантирует хронологию).
  - ручная проверка: добавлен make ddl-gp-ods (без него ODS-объекты не создадутся).
  - routes/flights FK: расширено пояснение, почему проверка по route_no без
    validity допустима в ODS и что учитывать при join в DDS.
- Проверка:
  - make test (11 passed, 4 skipped).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:44:08 +03:00
ddadminandClaude Opus 4.6 7bdb5acfcb docs(dwh): исправлен план ODS после ревью (v2 → v3)
- Зачем:
  - v2 содержал баги (CTE scope, distribution key), неточности маппинга
    колонок и пробелы в описании DAG-контракта, что сломало бы реализацию.
- Что:
  - исправлен CTE scope: дублирование CTE в UPDATE и INSERT statements.
  - ods.tickets: DISTRIBUTED BY (ticket_no) вместо book_ref для UPSERT.
  - добавлен Python-сниппет resolve_stg_batch_id (PythonOperator + XCom).
  - заменены {{ params.stg_batch_id }} → {{ ti.xcom_pull(...) }} во всех SQL.
  - <> заменён на IS DISTINCT FROM (NULL-safe change detection).
  - DISTINCT ON заменён на ROW_NUMBER() (стандартный DE-паттерн).
  - добавлена секция 4.3 с полной таблицей маппинга STG → ODS.
  - routes: scheduled_departure_time → departure_time, duration без rename.
  - DAG-граф: resolve_stg_batch_id как корневой таск, параллельные ветки.
  - DQ: добавлена проверка «батч не пустой» для snapshot-справочников.
  - DoD: убрано valid_from/valid_to, оставлено _load_id/_load_ts/event_ts.
- Проверка:
  - make test (11 passed, 4 skipped).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:39:16 +03:00
ddadmin 6ceff7b027 docs(dwh): зафиксированы конвенции нейминга и обновлён план ODS
- Зачем:
  - нужен единый стандарт именования полей, чтобы новые слои не расходились с учебными материалами.
- Что:
  - добавлен единый документ с правилами нейминга `docs/internal/naming_conventions.md`.
  - полностью переписан `docs/internal/bookings_ods_design.md` в эталонный учебный план ODS (SCD1, батч-контракт, DQ, граф DAG).
  - добавлены ссылки на стандарт нейминга в `docs/README.md`, `docs/internal/db_schema.md` и `AGENTS.md`.
- Проверка:
  - проверен diff по измененным файлам (`git diff`).
2026-02-22 22:45:53 +03:00
ddadmin d1e81099c7 docs(internal): добавлен план реализации ODS слоя
- Зачем:
  - зафиксировать архитектурное решение для ODS слоя перед началом разработки.
- Что:
  - создан файл docs/internal/bookings_ods_design.md.
  - описана архитектура ODS и отличия от STG слоя.
  - добавлены схемы таблиц (справочники с SCD Type 2, транзакции с UPSERT).
- Проверка:
  - git show --stat HEAD
2026-02-22 21:31:14 +03:00
ddadmin 26d0ab3d87 docs(testing): добавлены проверки source-данных перед DAG-тестами
- Зачем:
  - исключены ложные падения DQ при пустом source в bookings-db.
- Что:
  - в TESTING.md добавлены pre-check команды COUNT(*) перед Trigger DAG.
  - в docs/agent-dag-testing.md добавлена обязательная проверка source и fallback при COUNT(*) = 0.
  - в таблицу типичных проблем добавлен кейс падения check_airports_dq/check_airplanes_dq с ошибкой "..._ext нет строк".
- Проверка:
  - rg -n "source непустой|COUNT\(\*\) = 0|bookings-generate-day|check_airports_dq" TESTING.md docs/agent-dag-testing.md
2026-02-22 21:08:17 +03:00
ddadminandClaude Sonnet 4.6 2fb60e67ed docs(agents): добавлен гайд по программному тестированию DAG
- Зачем:
  - AI-агенты пытались тестировать DAG через браузер вместо CLI/API,
    так как не было явных инструкций по программному подходу.
- Что:
  - добавлен docs/agent-dag-testing.md: CLI, REST API Airflow,
    проверка параллельности, запросы в Greenplum, E2E-тест, шпаргалка команд.
  - в AGENTS.md добавлена ссылка на новый гайд в раздел «Тестирование».
  - из docs/README.md убрана ссылка (файл для людей, не для агентов).
- Проверка:
  - cat docs/agent-dag-testing.md && grep agent-dag-testing AGENTS.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 20:54:02 +03:00
ddadmin cb5dd0528b docs: добавлен CLAUDE.md с инструкциями для AI-ассистента
- Зачем:
  - необходимо зафиксировать контекст и правила работы с проектом для Claude AI.
- Что:
  - создан файл CLAUDE.md с описанием структуры проекта.
  - добавлены инструкции по коммитам и стилю кода.
- Проверка:
  - git show --stat HEAD
2026-02-22 20:27:16 +03:00
ddadminandClaude Opus 4.6 69651f98ff refactor(dags): параллелизован граф DAG bookings_to_gp_stage
- Зачем:
  - линейный граф маскировал реальные зависимости данных; для эталонного
    стенда важно показать менти параллельный граф там, где данные независимы.
- Что:
  - airports и airplanes грузятся параллельно после check_tickets_dq.
  - routes ждёт обоих (DQ проверяет ссылочную целостность на оба справочника).
  - seats зависит только от airplanes и работает параллельно с веткой
    routes → flights → segments → boarding_passes.
  - finish_summary ждёт обе ветки (check_boarding_passes_dq + check_seats_dq).
  - datetime.utcnow() заменён на datetime.now(UTC) в csv_to_greenplum.py.
  - smoke-тесты дополнены проверкой параллельности и второй ветки.
  - документация обновлена с ASCII-схемой нового графа.
- Проверка:
  - make test (11 passed, 4 skipped), make lint — чисто.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:19:58 +03:00
ddadmin a0258fd63e Доводка стилистики документации 2026-01-18 22:52:49 +03:00
ddadmin 9e08bc181c Улучшение тестов 2026-01-18 22:32:57 +03:00
ddadmin 843500cf52 Улучшение DQ проверки 2026-01-18 22:24:06 +03:00
ddadmin e5a9a07f01 Улучшения комментариев 2026-01-18 21:56:17 +03:00
ddadmin 761cf5dd93 статусы задач 2026-01-18 21:53:28 +03:00
ddadmin 8946df4a28 Допустимость пустых данных по некоторым таблицам 2026-01-18 21:48:47 +03:00
ddadmin ba74b521dc Исправлены критические замечания 2026-01-18 21:33:46 +03:00
ddadmin 762d539d52 Ревью решения 2026-01-18 21:22:00 +03:00
ddadmin 4718b3bdb7 Отладка потоков 2026-01-18 13:47:09 +03:00
ddadmin 9c033b39aa Генерация dds слоя по ТЗ - без тестов 2026-01-18 11:04:46 +03:00
ddadmin 15081384f4 Замечания от codex 2026-01-18 10:37:59 +03:00
ddadmin 457b47e415 план работ by glm 2026-01-17 23:53:20 +03:00
ddadmin d525cf56c4 Финальные доработки 2026-01-17 23:19:37 +03:00
ddadmin 520eacbfe2 Доработки GLM 2026-01-17 23:14:49 +03:00
ddadmin fec67dfad2 доработки плана 2026-01-17 22:41:25 +03:00
ddadmin 10a36b6dfb план витрин до stg слоя 2026-01-17 22:27:05 +03:00
ddadmin 9ec7e82f5f Полировка кода 2026-01-17 21:43:02 +03:00
ddadmin f36a98497a Проверка/рецензирование доработки 2026-01-17 19:36:02 +03:00
ddadmin 93ba59c1c9 актуализация "что делали" 2026-01-17 19:11:10 +03:00
ddadmin 875746972c Отладка 2026-01-17 15:47:59 +03:00
ddadmin 464e631eea Первая версия dag 2026-01-17 15:09:08 +03:00
ddadmin 0208e6fd71 План работ 2026-01-17 15:05:11 +03:00
ddadmin 23581b4f4a Вынесена настройка портов всех сервисов 2026-01-09 20:48:25 +03:00
ddadmin 61103847aa Полировка документации 2026-01-09 20:48:05 +03:00
ddadmin cf1438fceb Merge branch 'docs/main-docs' 2026-01-09 19:46:54 +03:00
ddadmin 2d76789050 мелкие правки 2026-01-09 19:39:42 +03:00
ddadmin 5f3e32c031 Глубокая переработка документации 2026-01-09 18:48:18 +03:00
ddadmin 16da5d0aae Улучшение документации - подчистили старые задачи 2026-01-09 18:04:29 +03:00
ddadmin db0e0259d6 Переход на LocalExecutor в AirFlow 2026-01-09 17:48:41 +03:00
ddadmin 991af3b2c3 Переход на LocalExecutor 2026-01-09 17:48:17 +03:00
ddadmin ff831b6bb2 Фикс DAG загрузки данных - построение дельты 2026-01-09 14:51:21 +03:00
ddadmin 5c3723a8c1 Merge branch 'fix/greenplum-pxf' 2026-01-09 14:03:57 +03:00
ddadmin 272013774b Сохранение логов airflow между перезапусками 2026-01-09 13:37:14 +03:00
ddadmin c3b2050cc4 еще баги и документирование сделанного 2026-01-09 12:41:36 +03:00
ddadmin 5668edeb7b Диагностика проблемы 2026-01-09 00:33:45 +03:00
ddadmin 810d3d4727 дальшейшая отладка 2026-01-08 23:51:30 +03:00
ddadmin 573c853f15 фикс pxf 2026-01-08 23:34:26 +03:00
ddadmin 1efeb58449 диагностика проблемы с pxf 2026-01-08 23:14:53 +03:00
ddadmin f81a831c0c патч 2026-01-08 22:52:56 +03:00
ddadmin 4751674ef3 планы по доработке 2026-01-08 22:41:11 +03:00
ddadmin dfcc456120 Документирование решения 2026-01-08 22:24:24 +03:00
ddadmin 5778ad6601 фикс бага pxf 2026-01-08 22:02:57 +03:00
ddadmin 33fc42f883 Merge branch 'chore/airflow-dockerfile' 2026-01-06 23:31:27 +03:00
ddadmin 1173e0e53f Отладка airflow dockerfile 2026-01-06 23:20:41 +03:00
ddadmin e27476fa5f Раздел "Благодарности" 2026-01-06 15:01:26 +03:00
ddadmin 4cb11b4769 feat: use custom docker image for airflow to avoid runtime pip install 2025-12-13 22:21:41 +03:00
ddadmin 9f028d0728 Доработки по full smoke тесту 2025-12-11 11:09:05 +03:00
ddadmin 0d81096ded полный smoke тест стенда 2025-12-11 10:51:39 +03:00
ddadmin 31171f84bc Баг генерации bookings 2025-12-11 10:34:16 +03:00
ddadmin 90aafd7b1d Фикс прав доступа у data раздела 2025-12-11 10:05:21 +03:00
ddadmin c27693b07f Планы по интеграции psql 2025-12-11 10:04:58 +03:00
ddadmin 8f92f2e890 раздел с благодарностями в ридми 2025-12-11 00:23:57 +03:00
ddadmin 98e0c90da0 Merge branch 'feature/bookings_db' 2025-12-11 00:01:41 +03:00
ddadmin 342f9bdb62 даг наконец-то работает 2025-12-10 23:49:01 +03:00
ddadmin 359245366a Способ тестирования даг 2025-12-10 23:43:35 +03:00
ddadmin d936f204bd Ошибка с вложенной транзакцией 2025-12-10 23:40:27 +03:00
ddadmin a8ac8f62dc проблемы с запуском GP 2025-12-10 23:32:45 +03:00
ddadmin 3b24d13908 фикс создания таблиц в make 2025-12-10 23:31:34 +03:00
ddadmin c0bb24764e фикс ошибки с etl 2025-12-10 23:23:34 +03:00
ddadmin 409a9f6f31 мысли по todo 2025-12-10 23:15:12 +03:00
ddadmin dfd6eca760 Удобная команда остановки стенда без удаления 2025-12-10 23:04:45 +03:00
ddadmin daed2313e7 Фикс создания connections 2025-12-10 22:58:19 +03:00
ddadmin 8fb78f9086 fix: добавленны airflow connections и обновлена структура запуска 2025-12-10 22:52:08 +03:00
ddadmin fbf9b118e6 фикс вфп инициализации ddl в gp 2025-12-10 22:31:12 +03:00
ddadmin 6e2a43a900 fix root issue for airflow-init 2025-12-10 22:15:49 +03:00
ddadmin 250af00b2f refactor(bookings): упростить работу с датами в DAG и SQL 2025-12-10 21:56:12 +03:00
ddadmin 29490828f4 Уход от запуска DAG за конкретную дату.
Теперь новый запуск генерирует и переливает новый, следующий день
2025-12-10 21:53:17 +03:00
ddadmin 7a9b2d182e Принесен isort 2025-12-10 18:49:49 +03:00
ddadmin c865fa282e feat(dags): add ddl dag for gp and rename csv dq 2025-12-10 18:41:00 +03:00
ddadmin cf95cf95d2 пояснения назначения файла 2025-12-10 17:36:38 +03:00
ddadmin c63084be09 ДОравботка структуры readme для лучшей читабильности 2025-12-10 17:33:55 +03:00
ddadmin fe4bb4606f Как сделать новый день 2025-12-10 17:13:08 +03:00
ddadmin cacf989a9c вынесли sql из dag в папку sql 2025-12-10 16:59:50 +03:00
ddadmin 2c8f5c1e23 Учебный даг первоначально протестирован 2025-12-09 11:22:56 +03:00
ddadmin 196171e3d6 Склелет учебного даг 2025-12-09 10:43:43 +03:00
ddadmin ae7f909f5d Проработка задачи на ETL 2025-12-09 10:39:47 +03:00
ddadmin c3d7759e6b Переименование БД GP в gp_dwh 2025-12-09 10:09:46 +03:00
ddadmin 4515577b4a Смена внешнего порта GreenPlum 2025-12-09 09:57:52 +03:00
ddadmin 2bdeb17cee Тестирование PXF 2025-12-09 09:52:50 +03:00
ddadmin e3b0fd9ef9 Первоначальная реализация PXF (не тестировано) 2025-12-09 09:42:52 +03:00
ddadmin 86435c282d Уточнения планов 2025-12-09 09:35:31 +03:00
ddadmin dbd2406765 Планы по включению PXF для GP 2025-12-09 00:09:33 +03:00
ddadmin 8bc8e5cb3a Улучшение документации по bookings 2025-12-08 23:34:39 +03:00
ddadmin b56ea952e7 Подключение bookings db заработало 2025-12-08 23:31:13 +03:00
ddadmin c17baf447d bookings debug 2025-12-08 22:08:07 +03:00
ddadmin c95dab6478 Доработки по генерации БД - в процессе 2025-12-08 18:26:10 +03:00
190 changed files with 19564 additions and 1077 deletions

No files matched your search

+21 -4
View File
@@ -1,12 +1,19 @@
# Общие настройки
TZ=Europe/Moscow
# Airflow Configuration
AIRFLOW_USER=admin
AIRFLOW_PASSWORD=admin
AIRFLOW__WEBSERVER__SECRET_KEY=your-super-secret-key-here-replace-with-actual-random-key
# Порт Airflow UI на хосте
AIRFLOW_WEB_PORT=8080
# PostgreSQL (Airflow metadata)
PG_USER=airflow
PG_PASSWORD=airflow
PG_DB=airflow
# Порт мета-БД Airflow на хосте (нужен в основном для диагностики)
PGMETA_PORT=5433
# Bookings demo database (Postgres)
BOOKINGS_DB_USER=bookings
@@ -15,16 +22,26 @@ BOOKINGS_DB_NAME=bookings
BOOKINGS_DB_PORT=5434
# Начальная дата модельного времени для генерации демобазы
BOOKINGS_START_DATE=2017-01-01
# Количество дней для генерации с нуля (make bookings-generate); для make bookings-init не используется
BOOKINGS_INIT_DAYS=60
# Количество параллельных джобов генератора (1 = синхронно без dblink, 2+ = через dblink).
# jobs=1 оптимален для инкрементальной генерации (+1 день ≈ 3 мин).
# jobs>1 на WSL2 в 3× медленнее из-за lock contention на gen.events.
BOOKINGS_JOBS=1
# Greenplum Configuration
GP_USER=gpadmin
GP_PASSWORD=gpadmin
GP_DB=gpadmin
GP_DB=gp_dwh
GP_HOST=greenplum
GP_PORT=5432
# Порт Greenplum на хосте (подключение из DBeaver/psql с ноутбука)
GP_HOST_PORT=5435
GP_CONN_ID=greenplum_conn
GP_USE_AIRFLOW_CONN=true
# CSV Pipeline
CSV_DIR=/opt/airflow/data
CSV_ROWS=1000
# PXF (optional)
# PXF_SEED_OVERWRITE=1 — принудительно перезаписать seed-файлы из образа в PXF_BASE (полезно после правок в pxf/)
PXF_SEED_OVERWRITE=0
# PXF_SYNC_ON_START=1 — выполнять `pxf cluster sync` при старте контейнера (дольше, но гарантирует актуальные конфиги)
PXF_SYNC_ON_START=0
+1
View File
@@ -7,3 +7,4 @@ __pycache__/
*.pyc
.venv/
data/
bookings/demodb/
+61 -33
View File
@@ -1,44 +1,67 @@
# Repository Guidelines (для агентa и контрибьюторов)
# Agent System Instructions & Repository Guidelines
Эта репа — учебный стенд для студентов (менти), которые только начинают с Airflow/Greenplum и Python. Пожалуйста, держите решения простыми, стабильными и хорошо объяснёнными.
Этот репозиторий — учебный DWH-стенд (Airflow, Greenplum, Postgres) для начинающих Data инженеров.
## Структура проекта
- `airflow/dags/` — DAG-файлы (например, `csv_to_greenplum.py`, `data_quality_greenplum.py`).
- `airflow/requirements.txt` — зависимости, которые ставятся внутри контейнеров Airflow.
- `sql/` — DDL и вспомогательные SQL (например, `sql/ddl_gp.sql`).
- `docker-compose.yml` — Greenplum, Airflow, Postgres (мета-БД).
- `Makefile` — удобные команды для локальной работы.
- `.env(.example)` — настройки окружения (реальные секреты не коммитим).
## 1. Главное правило генерации кода (Баланс)
Создаваемый код должен иметь учебную ценность: быть эталоном для "боевого" применения, но без избыточного усложнения (over-engineering).
- **Production-ready:** Учитывайте идемпотентность DAG'ов, транзакционность, отсутствие хардкода секретов.
- **KISS:** Не используйте сложные ООП-паттерны, метапрограммирование или избыточные абстракции, если задачу решает стандартный оператор (например, `PostgresOperator`).
- **Фокус на «Почему»:** При использовании специфичных паттернов DWH (например, `delete + insert` для инкремента в Greenplum вместо `merge`) — добавляйте краткий комментарий, объясняющий этот выбор студентам.
## Команды (основные)
- `make up` — поднять весь стек.
- `make airflow-init` — инициализировать мета-БД Airflow и создать пользователя.
- `make logs` — логи webserver и scheduler.
- `make ddl-gp` — применить DDL к Greenplum.
- `make gp-psql` — открыть `psql` в контейнере Greenplum от `gpadmin`.
- `make down` — остановить и удалить тома (данные будут потеряны).
## 2. Карта проекта (Навигация для Агента)
- `airflow/dags/` — DAG-файлы (напр. `bookings_to_gp_stage.py`).
- `sql/` — DDL и SQL-скрипты. Разделены на слои: src/ (исходные системы), stg/ (стейджинг), ods/ (операционное хранилище), dds/ (детальное хранилище), dm/ (слой витрин).
- *Правило ИИ:* DDL таблиц хранится строго рядом с объектом (напр. `sql/stg/bookings_ddl.sql`).
- `docs/` — документация. Подкаталоги: `design/` (дизайн, стандарты), `reference/` (справочники), `plans/` (активные планы), `archive/` (выполненные планы), `assignment/` (задание для студента).
- `docs/design/naming_conventions.md` — единый источник нейминга служебных и SCD-полей. *Правило ИИ: всегда сверяться при генерации DDL/SQL.*
- Нейминг файлов планов: `YYYY-MM-DD_краткое-описание.md` (дата — ISO 8601, `_` отделяет дату от описания, описание в kebab-case, без суффикса `-plan`).
- `tests/` — pytest-тесты (smoke-тесты DAG'ов и юнит-тесты).
- `.env` — Настройки окружения (все секреты `GP_*`, `AIRFLOW_*` берем только отсюда).
Пример: `make up && make airflow-init`, затем открыть `http://localhost:8080`.
## 3. Среда и Инструменты (Терминал)
Мы используем **uv** для управления зависимостями и **make** для автоматизации.
- *Запрещено* использовать `pip install --user`. Только `uv`.
- Если нужно запустить команду в терминале, используйте `uv run ...`
- Доступные Make-таргеты (агент может вызывать их для проверок):
- `make fmt`, `make lint` — форматирование (`black`, `isort`) и линтинг кода.
- `make test` — запуск `pytest`.
- `make up` / `make stop` / `make down` — управление контейнерами docker-compose.
- `make ddl-gp` — накат DDL на Greenplum.
## Локальное Python‑окружение
- Используем `uv`: достаточно `uv sync` (или `make dev-sync`) — подтянет Python, создаст `.venv`, установит зависимости.
- `make dev-setup` полезен при смене версии Python (выполнит `uv python install` + `uv python pin` перед `uv sync`).
- Проверки: `make test`, `make lint`, `make fmt` (выполняются через `uv run`).
- Не используем `pip install --user`; если что‑то попало в user‑site — удалить `pip uninstall <package>` и проверить `pip list --user`.
- В IDE выбираем интерпретатор из `.venv`.
## 4. Airflow + SQL Специфика
- В учебных DAG'ах основная логика выносится в SQL. По умолчанию используйте `PostgresOperator` + Airflow Connections (`sql='sql/stg/bookings_load.sql'`).
- Сложную работу с соединениями (например, `psycopg2` напрямую в Python) используйте только там, где реально много Python-логики и это служит учебной цели.
## Стиль кода
- Python: PEP 8, 4 пробела, `snake_case`; `dag_id` — `lower_snake_case`.
- Импорты: stdlib → third‑party → local, по одному модулю в строке.
- SQL: ключевые слова UPPERCASE, идентификаторы `snake_case`, завершаем `;`.
- Форматирование: `black` (88 cols) и `isort`. Если не уверены — запустите `make fmt`.
- Язык: комментарии, docstring и документацию — на русском; имена идентификаторов — на английском.
## 5. Стиль и Оформление
- **Язык:** Комментарии, docstrings, тексты ошибок — на **русском** языке. Ошибки должны быть дружелюбными и подсказывать студенту, что делать дальше.
- **Код:** Переменные, функции, SQL-идентификаторы (`snake_case`) — на английском.
- **Коммиты:** Придерживайтесь Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`). Если меняется логика DAG'а, в описании PR (или коммита) указывайте, что именно изменилось.
### Структура и нейминг SQL (слои DWH)
- В каталоге `sql/` придерживаемся слоёв DWH:
- `sql/src/` — скрипты, работающие с исходными системами (например, `bookings_generate_day_if_missing.sql`);
- `sql/stg/` — скрипты для стейджинга (`bookings_ddl.sql`, `bookings_load.sql`, `bookings_dq.sql`);
- `sql/ods/` — скрипты ODS (операционное хранилище);
- `sql/dds/` — скрипты DDS (детальное хранилище, star schema);
- `sql/dm/` — скрипты DM (витрины / data marts).
- Нейминг служебных полей и SCD-полей фиксирован в `docs/design/naming_conventions.md` (единый источник для всех новых слоёв).
- Именование файлов: `{объект}_{роль}.sql`, где:
- `объект` — логическое имя сущности (`bookings`, `orders`, и т.п.);
- `роль` — `ddl` (создание/изменение объектов), `load` (загрузка/инкремент), `dq` (проверки качества данных) и т.п.
- Общие DDL-скрипты (например, `sql/ddl_gp.sql`) могут подключать файловые DDL через `\i`, но сами определения таблиц живут рядом с объектом (`sql/stg/bookings_ddl.sql` и т.п.).
### Airflow + SQL
- В учебных DAG’ах, где основная логика — в SQL, по умолчанию используем `PostgresOperator` + Airflow Connections:
- DAG оркестрирует шаги и подключение к БД;
- SQL-скрипты лежат в `sql/...` и подключаются по пути (`sql='sql/stg/bookings_load.sql'`).
- Сложную ручную работу с подключениями (`psycopg2`, ENV-фоллбеки) используем только там, где реально много Python-логики и это помогает учебной цели.
## Тестирование
- Тесты лежат в `tests/` (pytest). Запуск: `make test`.
- Есть юнит‑тесты для `helpers/greenplum.py` и smoke‑тесты DAG‑структуры (`tests/test_dags_smoke.py`).
- Есть smoke‑тесты DAG‑структуры (`tests/test_dags_smoke.py`).
- Smoke‑тесты DAG автоматически пропускаются, если Airflow не установлен в venv.
- Для ручного прогона стенда см. `TESTING.md` (пошаговый чек‑лист для студентов).
- Для программной проверки DAG (без браузера) — см. `docs/agent-dag-testing.md`: CLI, REST API, проверка параллельности, запросы в Greenplum.
## Pull Requests
- Conventional Commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`. Пример: `feat(dags): load orders to Greenplum`.
@@ -47,11 +70,16 @@
- При изменении схемы/поведения — обновляйте `README.md` и `sql/ddl_gp.sql`.
## Безопасность и конфигурация
- Все настройки — через `.env`; креды в коде не хардкодим. Частые переменные: `GP_*`, `PG_*`, `AIRFLOW_*`, `CSV_*`.
- `make down` удаляет тома — предупреждайте студентов, что данные пропадут.
- Все настройки — через `.env`; креды в коде не хардкодим. Частые переменные: `GP_*`, `PG_*`, `AIRFLOW_*`.
- `make clean` удаляет тома — предупреждайте студентов, что данные пропадут.
## Для агента (особенности аудитории)
При создании и пересмотре лабораторных сначала прочитайте
[развитие лабораторных](docs/design/labs_development.md): аудитория,
принятые решения и критерии заданий.
- Пишите простыми словами. Добавляйте короткие комментарии к нетривиальной логике.
- Избегайте больших рефакторингов и сложных паттернов — студенты только начинают.
- Ошибки и логи — дружелюбные и понятные (лучше с подсказкой «что сделать дальше»).
- Перед релевантными правками валидируйте локально: `make up && make airflow-init`, затем откройте DAG в UI и/или прогоните `make test`.
- Перед релевантными правками валидируйте локально: `make up`, затем откройте DAG в UI и/или прогоните `make test`.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+24
View File
@@ -0,0 +1,24 @@
# Apache Airflow с дополнительными зависимостями для Greenplum
FROM apache/airflow:2.9.2
LABEL maintainer="your-email@example.com"
LABEL description="Airflow with Greenplum and Pandas dependencies"
LABEL version="1.0"
# Устанавливаем psql для ручной диагностики внутри контейнера
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Копируем requirements в стандартное расположение
COPY airflow/requirements.txt /opt/airflow/requirements.txt
# Устанавливаем зависимости
USER airflow
RUN pip install --no-cache-dir -r /opt/airflow/requirements.txt \
&& pip check \
&& rm -rf /tmp/pip-*
WORKDIR /opt/airflow
+16
View File
@@ -0,0 +1,16 @@
FROM woblerr/greenplum:6.27.1
ENV PXF_SEED_DIR=/opt/pxf-seed \
PXF_SEED_OVERWRITE=0 \
PXF_SYNC_ON_START=0
RUN mkdir -p /opt/pxf-seed/servers/bookings-db /opt/pxf-scripts
COPY pxf/postgresql-42.7.3.jar /opt/pxf-seed/postgresql-42.7.3.jar
COPY pxf/servers/bookings-db/jdbc-site.xml /opt/pxf-seed/servers/bookings-db/jdbc-site.xml
COPY pxf/init/10_pxf_bookings.sh /opt/pxf-scripts/ensure_pxf_bookings.sh
COPY pxf/init/start_greenplum_with_pxf.sh /start_greenplum_with_pxf.sh
RUN chmod 755 /opt/pxf-scripts/ensure_pxf_bookings.sh /start_greenplum_with_pxf.sh
CMD ["/start_greenplum_with_pxf.sh"]
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+184 -9
View File
@@ -1,15 +1,32 @@
SHELL := /bin/bash
UV := uv
PYTHON_VERSION := 3.11
DEMODB_REPO := https://github.com/postgrespro/demodb.git
DEMODB_COMMIT := 866e56f7fe54596a1d2a88f5f32f4aa3b2698121
BOOKINGS_JOBS ?= 1
BOOKINGS_START_DATE ?= 2017-01-01
BOOKINGS_INIT_DAYS ?= 60
.PHONY: up down airflow-init logs gp-psql ddl-gp \
bookings-clone-demodb bookings-init bookings-psql bookings-generate-day \
dev-setup dev-sync dev-lock test lint fmt clean-venv
.PHONY: up stop down clean airflow-init logs gp-psql ddl-gp \
bookings-check-jobs bookings-clone-demodb bookings-init bookings-generate bookings-dump \
bookings-psql bookings-generate-day \
dev-setup dev-sync dev-lock test lint fmt clean-venv build e2e-smoke e2e-etl
BOOKINGS_SEED := bookings/seed/demo.sql.xz
SHELL := /bin/bash
up:
docker compose -f docker-compose.yml up -d
build:
docker compose -f docker-compose.yml build
stop:
docker compose -f docker-compose.yml stop
down:
docker compose -f docker-compose.yml down
clean:
docker compose -f docker-compose.yml down -v
airflow-init:
@@ -19,25 +36,176 @@ logs:
docker compose -f docker-compose.yml logs -f airflow-webserver airflow-scheduler
gp-psql:
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -p 5432 -d gpadmin'"
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -p 5432 -d gp_dwh'"
dwh-truncate:
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c 'cd /sql && /usr/local/greenplum-db/bin/psql -d gp_dwh -f truncate_gp.sql'"
ddl-gp:
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -d gpadmin -f /sql/ddl_gp.sql'"
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c 'cd /sql && /usr/local/greenplum-db/bin/psql -d gp_dwh -f ddl_gp.sql'"
bookings-clone-demodb:
mkdir -p bookings
test -d bookings/demodb || git clone --depth 1 https://github.com/postgrespro/demodb.git bookings/demodb
if [ ! -d bookings/demodb ]; then \
git clone --depth 1 $(DEMODB_REPO) bookings/demodb; \
git -C bookings/demodb fetch --depth 1 origin $(DEMODB_COMMIT); \
git -C bookings/demodb checkout $(DEMODB_COMMIT); \
fi
# Патчим generate/continue: при jobs=1 запускаем process_queue синхронно, без dblink
if ! grep -q "Job 1 (local): ok" bookings/demodb/engine.sql; then \
if ! patch -d bookings/demodb -p1 --forward < bookings/patches/engine_jobs1_sync.patch; then \
echo "Не удалось применить патч engine_jobs1_sync.patch. Удалите bookings/demodb и повторите make bookings-generate." >&2; \
exit 1; \
fi; \
fi
# Делаем установку идемпотентной и принудительной: DROP DATABASE IF EXISTS demo WITH (FORCE)
if ! grep -q "DROP DATABASE IF EXISTS demo WITH (FORCE);" bookings/demodb/install.sql; then \
if ! patch -d bookings/demodb -p1 --forward < bookings/patches/install_drop_if_exists.patch; then \
echo "Не удалось применить патч install_drop_if_exists.patch. Удалите bookings/demodb и повторите make bookings-generate." >&2; \
exit 1; \
fi; \
fi
# Убираем хардкод gen.connstr без credentials — иначе VACUUM падает
if grep -q "gen.connstr = 'dbname=demo'" bookings/demodb/install.sql; then \
if ! patch -d bookings/demodb -p1 --forward < bookings/patches/install_connstr_no_hardcode.patch; then \
echo "Не удалось применить патч install_connstr_no_hardcode.patch. Удалите bookings/demodb и повторите make bookings-generate." >&2; \
exit 1; \
fi; \
fi
bookings-init: bookings-clone-demodb
bookings-check-jobs:
@case "$(BOOKINGS_JOBS)" in \
''|*[!0-9]*) echo "BOOKINGS_JOBS должен быть целым числом >= 1. Текущее значение: '$(BOOKINGS_JOBS)'." >&2; exit 1;; \
esac; \
if [ "$(BOOKINGS_JOBS)" -lt 1 ]; then \
echo "BOOKINGS_JOBS должен быть >= 1. Текущее значение: $(BOOKINGS_JOBS)." >&2; \
exit 1; \
fi
bookings-generate: bookings-check-jobs bookings-clone-demodb
docker compose -f docker-compose.yml up -d bookings-db
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
until PGPASSWORD="$$POSTGRES_PASSWORD" pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -h localhost; do \
echo "Waiting for bookings-db to become ready..."; \
sleep 1; \
done \
'
docker compose -f docker-compose.yml exec bookings-db bash -lc 'cd /bookings/demodb && PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -f install.sql'
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
CONNSTR="dbname=demo user=$$POSTGRES_USER password=$$POSTGRES_PASSWORD"; \
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -c "ALTER DATABASE demo SET gen.connstr='\''$$CONNSTR'\'';" \
'
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d demo -c "\
ALTER DATABASE demo SET bookings.start_date = '\''$(BOOKINGS_START_DATE)'\''; \
ALTER DATABASE demo SET bookings.init_days = '\''$(BOOKINGS_INIT_DAYS)'\''; \
ALTER DATABASE demo SET bookings.jobs = '\''$(BOOKINGS_JOBS)'\'';" \
'
# Генерируем данные за $(BOOKINGS_INIT_DAYS) дней
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d demo -f /bookings/generate_next_day.sql \
'
# Валидация: ключевые таблицы не должны быть пустыми
@docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d demo -tAc " \
SELECT format(E'\''%-25s %s'\'', t, cnt) \
FROM ( \
SELECT '\''bookings.bookings'\'' AS t, count(*) AS cnt FROM bookings.bookings \
UNION ALL \
SELECT '\''bookings.tickets'\'', count(*) FROM bookings.tickets \
UNION ALL \
SELECT '\''bookings.flights'\'', count(*) FROM bookings.flights \
UNION ALL \
SELECT '\''bookings.boarding_passes'\'', count(*) FROM bookings.boarding_passes \
) s ORDER BY t; \
" \
'
@docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d demo -tAc " \
DO \$$\$$ \
DECLARE v_cnt bigint; \
BEGIN \
SELECT count(*) INTO v_cnt FROM bookings.bookings; \
IF v_cnt = 0 THEN \
RAISE EXCEPTION '\''bookings.bookings пуста — генерация не сработала. Проверьте логи: SELECT * FROM gen.log ORDER BY at DESC LIMIT 10;'\''; \
END IF; \
SELECT count(*) INTO v_cnt FROM bookings.flights; \
IF v_cnt = 0 THEN \
RAISE EXCEPTION '\''bookings.flights пуста — попробуйте увеличить BOOKINGS_INIT_DAYS.'\''; \
END IF; \
END \$$\$$; \
" \
'
bookings-init: bookings-check-jobs
@if [ ! -f $(BOOKINGS_SEED) ]; then \
echo "Файл $(BOOKINGS_SEED) не найден. Используйте make bookings-generate для генерации с нуля." >&2; \
exit 1; \
fi
docker compose -f docker-compose.yml up -d bookings-db
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
until PGPASSWORD="$$POSTGRES_PASSWORD" pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -h localhost; do \
echo "Waiting for bookings-db to become ready..."; \
sleep 1; \
done \
'
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" \
-c "DROP DATABASE IF EXISTS demo WITH (FORCE);" \
'
@echo "Восстановление из дампа (~42 MB)..."
xz -dc $(BOOKINGS_SEED) | docker compose -f docker-compose.yml exec -T bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" \
'
# Применяем настройки из текущего окружения поверх дампа
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
CONNSTR="dbname=demo user=$$POSTGRES_USER password=$$POSTGRES_PASSWORD"; \
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -c "\
ALTER DATABASE demo SET gen.connstr='\''$$CONNSTR'\''; \
ALTER DATABASE demo SET bookings.start_date = '\''$(BOOKINGS_START_DATE)'\''; \
ALTER DATABASE demo SET bookings.init_days = '\''$(BOOKINGS_INIT_DAYS)'\''; \
ALTER DATABASE demo SET bookings.jobs = '\''$(BOOKINGS_JOBS)'\'';" \
'
@echo "Восстановление завершено. Проверяем данные..."
@docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -U "$$POSTGRES_USER" -d demo -tAc " \
SELECT format(E'\''%-25s %s'\'', t, cnt) \
FROM ( \
SELECT '\''bookings.bookings'\'' AS t, count(*) AS cnt FROM bookings.bookings \
UNION ALL \
SELECT '\''bookings.tickets'\'', count(*) FROM bookings.tickets \
UNION ALL \
SELECT '\''bookings.flights'\'', count(*) FROM bookings.flights \
UNION ALL \
SELECT '\''bookings.boarding_passes'\'', count(*) FROM bookings.boarding_passes \
) s ORDER BY t; \
" \
'
bookings-dump:
@echo "Создание дампа demo → $(BOOKINGS_SEED)..."
mkdir -p bookings/seed
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" pg_dump -U "$$POSTGRES_USER" -d demo --format=plain --create \
' > /tmp/demo_dump_$$$$.sql
xz -9 < /tmp/demo_dump_$$$$.sql > $(BOOKINGS_SEED)
rm -f /tmp/demo_dump_$$$$.sql
@echo "Дамп сохранён: $$(ls -lh $(BOOKINGS_SEED) | awk '{print $$5}')"
bookings-psql:
docker compose -f docker-compose.yml exec bookings-db bash -lc 'PGPASSWORD="$$POSTGRES_PASSWORD" psql -U "$$POSTGRES_USER" -d demo'
bookings-generate-day:
bookings-generate-day: bookings-check-jobs
docker compose -f docker-compose.yml up -d bookings-db
docker compose -f docker-compose.yml exec bookings-db bash -lc 'PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d demo -v start_date="$${BOOKINGS_START_DATE:-2017-01-01}" -f /bookings/generate_next_day.sql'
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
until PGPASSWORD="$$POSTGRES_PASSWORD" pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -h localhost; do \
echo "Waiting for bookings-db to become ready..."; \
sleep 1; \
done \
'
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d demo -f /bookings/generate_next_day.sql \
'
dev-setup:
$(UV) python install $(PYTHON_VERSION)
@@ -63,3 +231,10 @@ fmt:
clean-venv:
python -c "import shutil; shutil.rmtree('.venv', ignore_errors=True)"
e2e-smoke:
./scripts/e2e_smoke.sh
e2e-etl:
./scripts/e2e_etl.sh
+128 -236
View File
@@ -1,287 +1,179 @@
# airflow-dwh-gp-lab
# airflow-dwh-gp-lab
Учебный стенд: ETL из Postgres в Greenplum с оркестрацией в Airflow.
> Этот стенд можно использовать самостоятельно, но он также является курсовой работой
> плана обучения [Data Engineering Roadmap](https://github.com/dementev-dev/de-roadmap).
> Нужна помощь? Обратитесь к автору материалов — ментору [@dementev_dev](https://t.me/dementev_dev).
Добро пожаловать в учебный стенд для изучения основ Data Engineering! Этот проект поможет вам освоить ключевые инструменты современных data pipeline: **Airflow** для оркестрации, **pandas/CSV** для подготовки данных, **Postgres** с демобазой **bookings** как источник и **Greenplum** как аналитическую базу данных.
Учебный стенд для лабораторных по Data Engineering: **Airflow** оркестрирует загрузку данных из демо‑БД
**bookings** (Postgres) в **Greenplum**.
## 🎯 Что вы узнаете
## Зачем этот стенд
- Как настроить локальный стек данных с помощью Docker
- Как Airflow управляет workflow и координирует задачи
- Как генерировать датасеты через pandas и сохранять их в CSV
- Как загружать данные в Greenplum пакетами и избегать дублей
- Как проверять качество данных в автоматизированных pipeline
- Основы проектирования ETL/ELT процессов
- Как работать с демо-БД bookings в Postgres как источником для будущего DWH
Основная цель стенда — дать вам удобное место для лабораторных работ и будущей курсовой:
вы построите небольшое аналитическое хранилище данных (DWH) на базе **Greenplum** и закрепите навыки:
## 👩‍🎓 Для студентов (10‑минутный чек‑лист)
- моделирования данных (слои DWH, модели, витрины);
- построения ETL/ELT;
- работы с Airflow и Greenplum.
- Установите Docker Desktop и Git.
- Скопируйте настройки: `cp .env.example .env`.
- Поднимите стенд: `docker compose up -d` и инициализируйте Airflow: `docker compose run --rm airflow-init`.
- Откройте UI: http://localhost:8080 (admin/admin).
- Включите и запустите DAG `csv_to_greenplum`. Дождитесь Success.
- Проверьте данные: `make gp-psql` → `SELECT COUNT(*) FROM public.orders;`.
- Дополнительно: запустите `greenplum_data_quality` — все проверки должны быть зелёные.
В курсовой у нас один источник данных — демо‑БД **bookings**. В стенде уже есть готовые учебные примеры
загрузки **bookings → STG → ODS → DDS → DM** в Greenplum, чтобы вы могли сфокусироваться
на DWH‑части и не тратить время на инфраструктуру.
Если что‑то не работает — смотрите «Типичные проблемы» и «Быстрый reset» ниже.
## Что внутри
## 🚀 Быстрый старт (для новичков)
- **Airflow** (UI: http://localhost:8080, по умолчанию; логин/пароль: admin/admin)
- **Greenplum** (single‑node для обучения; внешний порт по умолчанию `5435`)
- **bookings-db** (Postgres с демо‑БД `demo`; внешний порт по умолчанию `5434`)
- **PXF** как “транспорт” между Postgres и Greenplum (уже настроен в образе)
### Шаг 1: Подготовка окружения
## Требования
**Требования:**
- Docker Desktop (Windows/Mac) или Docker Engine 24+ (Linux)
- Git для клонирования репозитория
Стенд запускается в Docker и поднимает несколько сервисов (Airflow + Greenplum + 2×Postgres),
поэтому важны ресурсы и настройки Docker.
> 💡 **Совет:** Если у вас Windows, рекомендуем использовать WSL (Windows Subsystem for Linux) для лучшей совместимости.
| Ресурс | Минимум | Рекомендуем |
|---|---:|---:|
| CPU | 2 ядра | 4+ ядер |
| RAM | 8 GB | 16 GB |
| Диск | 25–30 GB свободного места | 40+ GB (SSD) |
### Шаг 2: Настройка проекта
Работает на **Linux**, а на **Windows** — при использовании **WSL2**.
Потребуется `make`, Docker (`docker`) и Docker Compose (`docker compose`) или Docker Desktop.
Для первого запуска нужен доступ в интернет (скачать образы и зависимости).
Порты `8080`, `5433`, `5434`, `5435` должны быть свободны (или измените их в `.env`).
Порты настраиваются в `.env`: `AIRFLOW_WEB_PORT`, `PGMETA_PORT`, `BOOKINGS_DB_PORT`, `GP_HOST_PORT`.
Про PXF и технические детали стенда: [docs/stack.md](docs/stack.md).
## Быстрый старт (основной сценарий: bookings → STG → ODS → DDS → DM)
1) Скопируйте настройки:
```bash
# Скопируйте файл настроек
cp .env.example .env
# Запустите стек (это может занять 2-3 минуты при первом запуске)
docker compose up -d
```
### Шаг 3: Первый запуск pipeline
1. Откройте Airflow UI: **http://localhost:8080** (логин/пароль: admin/admin)
2. Найдите DAG с названием **csv_to_greenplum**
3. Нажмите на переключатель слева от названия DAG, чтобы включить его
4. Нажмите кнопку **Trigger** (значок воспроизведения ▶️)
🎉 **Поздравляем!** Вы только что запустили свой первый data pipeline:
- Система сгенерировала 1000 тестовых заказов при помощи pandas
- Датасет сохранился в CSV-файл в каталоге `./data`
- Airflow загрузил данные из CSV в Greenplum без дублей по `order_id`
### Шаг 4: Проверка результатов
**Проверка вручную:**
```bash
# Подключитесь к Greenplum и проверьте данные
docker compose exec greenplum bash -c "su - gpadmin -c 'psql -p 5432 -d gpadmin'"
# Внутри psql выполните:
\dt # Показать таблицы
SELECT count(*) FROM public.orders; # Посчитать записи
# Посмотреть несколько строк
SELECT * FROM public.orders LIMIT 5;
```
CSV-файлы после выполнения DAG остаются в директории `./data`. Их можно открыть любым редактором или изучить через pandas.
### Быстрый reset
Если после изменений что‑то «сломалось»:
2) Поднимите стенд:
```bash
make down # Остановить и стереть данные в контейнерах
make up
# если make не установлен: docker compose up -d
```
Это помогает, когда Greenplum не стартует из‑за «грязной» остановки и внутренних файлов.
---
## 🛠️ Подробная настройка (для уверенных пользователей)
### Установка Make (опционально)
Для удобства работы с проектом рекомендуем установить `make`:
- **Linux (Debian/Ubuntu):** `sudo apt install -y make`
- **macOS:** `brew install make`
- **Windows:**
- WSL: `sudo apt install -y make`
- Chocolatey: `choco install make`
- Scoop: `scoop install make`
С `make` команды становятся короче:
```bash
make up && make airflow-init # Запуск стека
make logs # Просмотр логов
make gp-psql # Подключение к Greenplum
```
### Настройка подключения к Greenplum в Airflow
По умолчанию DAG использует переменные окружения, но вы можете создать Airflow Connection:
1. Airflow UI → **Admin → Connections → Add a new record**
2. Заполните поля:
- **Conn Id:** `greenplum_conn`
- **Conn Type:** `Postgres`
- **Host:** `greenplum`
- **Schema:** `gpadmin`
- **Login:** `gpadmin`
- **Password:** `gpadmin`
- **Port:** `5432`
---
### Локальное окружение разработчика
Локальным окружением управляет [uv](https://docs.astral.sh/uv/) — он скачивает нужный Python и создаёт `.venv` на основе `pyproject.toml` / `uv.lock`.
3) Инициализируйте демо‑БД bookings:
```bash
uv sync
make bookings-init # быстрое восстановление из seed-дампа (~18 сек), рекомендуется
# make bookings-generate # альтернатива: полная генерация с нуля (занимает часы)
```
`uv sync` сам подтянет версию Python из `.python-version`/`pyproject.toml`, создаст `.venv` и установит зависимости. Для тех же действий можно использовать `make dev-sync`. Цель `make dev-setup` (или вручную `uv python install` + `uv python pin`) нужна только когда вы меняете версию Python или прогреваете кэш.
4) Подготовьте объекты DWH в Greenplum (выберите один вариант):
> Если требуется «классическое» активированное окружение, после `uv sync` выполните `.\.venv\Scripts\Activate.ps1` в PowerShell или `source .venv/bin/activate` в Unix-терминале.
- Учебный вариант: в Airflow UI запустите DAG'и по порядку — `bookings_stg_ddl`, `bookings_ods_ddl`, `bookings_dds_ddl`, `bookings_dm_ddl`;
- Технический шорткат: `make ddl-gp` (применяет DDL для STG, ODS, DDS и DM разом).
Проверки и форматирование выполняем через uv:
5) Запустите DAG'и загрузки данных по порядку:
- `bookings_to_gp_stage` — загрузка в STG
- `bookings_to_gp_ods` — загрузка в ODS
- `bookings_to_gp_dds` — загрузка в DDS
- `bookings_to_gp_dm` — загрузка в DM (витрины)
6) Проверьте результат в Greenplum:
```bash
make test # uv run pytest -q
make lint # black/isort в режиме проверки
make fmt # автоформатирование black + isort
make gp-psql
-- внутри psql:
SELECT COUNT(*) FROM stg.bookings;
SELECT COUNT(*) FROM stg.tickets;
SELECT * FROM stg.bookings ORDER BY event_ts DESC LIMIT 10;
SELECT COUNT(*) FROM ods.bookings;
SELECT COUNT(*) FROM ods.tickets;
SELECT COUNT(*) FROM dds.dim_routes;
SELECT COUNT(*) FROM dds.fact_flight_sales;
SELECT COUNT(*) FROM dm.sales_report;
SELECT COUNT(*) FROM dm.route_performance;
```
#### Быстрый старт с uv
Подробнее про логику DAG и проверки — `docs/bookings_to_gp_stage.md`.
7) Изучите готовую реализацию:
Весь пайплайн `STG → ODS → DDS → DM` на этой ветке полностью реализован.
Переходите к [навигатору по реализации](docs/assignment/README.md) —
там описана структура, ключевые паттерны и рекомендуемый порядок изучения.
## DAG-и в стенде
Основные (для потока bookings → DWH):
- `bookings_stg_ddl` — создаёт/обновляет весь STG слой для bookings (9 таблиц: bookings, tickets, airports, airplanes,
routes, seats, flights, segments, boarding_passes; включая внешние `*_ext` через PXF);
- `bookings_to_gp_stage` — генерирует учебный день в `bookings-db`, затем загружает данные в STG и выполняет DQ‑проверки.
- `bookings_ods_ddl` — создаёт/обновляет ODS-таблицы по домену bookings.
- `bookings_to_gp_ods` — загружает данные из STG в ODS (SCD1 UPSERT) и выполняет DQ‑проверки.
- `bookings_dds_ddl` — создаёт/обновляет DDS-таблицы (`dim_*`, `fact_flight_sales`) по домену bookings.
- `bookings_to_gp_dds` — загружает данные из ODS в DDS (SCD1/SCD2 + факт) и выполняет DQ‑проверки.
- `bookings_dm_ddl` — создаёт/обновляет DM-витрины (sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview).
- `bookings_to_gp_dm` — загружает данные из DDS в DM (агрегированные витрины).
## Полезные команды
```bash
uv sync
uv run pytest -q
uv run black --check airflow tests
make up # поднять стек
make logs # логи airflow-webserver и airflow-scheduler
make gp-psql # psql в Greenplum
make bookings-psql # psql в демо-БД bookings (Postgres)
make ddl-gp # применить DDL STG+ODS+DDS+DM к Greenplum вручную (вместо DDL-DAG)
make down # остановить и удалить контейнеры/сети (volumes сохраняются)
make clean # полный reset: удалить контейнеры/сети и volumes (данные будут потеряны)
```
> Не устанавливайте пакеты напрямую через `pip install --user ...`. Если что-то уже попало в user-site, удалите `pip uninstall <package>` и проверьте `pip list --user`.
---
## Подключение через DBeaver (опционально)
## 📋 Что входит в стенд
### Основные компоненты
- **Greenplum** — аналитическая база данных для хранения и анализа данных
- **Airflow** — оркестратор workflow и задач
- **Postgres** — база метаданных для Airflow
- **Postgres (bookings)** — отдельная демо-БД bookings (источник данных для будущего DWH в Greenplum)
- **pandas** — библиотека для генерации и анализа данных в формате CSV
### Готовые DAG (workflow)
- **csv_to_greenplum** — базовый pipeline: pandas → CSV → Greenplum
- **greenplum_data_quality** — проверки качества данных (наличие таблицы, схема, дубликаты)
### Полезные команды
```bash
# Основные команды
make up # Запустить весь стенд
make down # Остановить и удалить данные
make airflow-init # Инициализировать Airflow
make ddl-gp # Применить DDL к Greenplum
make gp-psql # Подключиться к Greenplum через psql
make bookings-init # Установить демобазу bookings в Postgres
make bookings-generate-day # Добавить ещё один день данных в bookings
make bookings-psql # Подключиться к демобазе bookings (БД demo)
# Проверка данных
make logs # Следить за логами Airflow
```
---
## ⚙️ Настройка через переменные окружения
Все настройки находятся в файле `.env`. Основные параметры:
Перед подключением убедитесь, что стенд поднят (`make up`).
### Greenplum
- `GP_USER` — пользователь (по умолчанию: gpadmin)
- `GP_PASSWORD` — пароль (по умолчанию: gpadmin)
- `GP_DB` — база данных (по умолчанию: gpadmin)
- `GP_PORT` — порт (по умолчанию: 5432)
### Демо-БД bookings (Postgres)
- `BOOKINGS_DB_USER` — пользователь Postgres для демобазы (по умолчанию: bookings)
- `BOOKINGS_DB_PASSWORD` — пароль пользователя (по умолчанию: bookings)
- `BOOKINGS_DB_NAME` — база данных, из которой запускается установка генератора (по умолчанию: bookings)
- `BOOKINGS_DB_PORT` — внешний порт для подключения к контейнеру bookings-db (по умолчанию: 5434)
- `BOOKINGS_START_DATE` — начальная дата модельного времени для первой генерации данных (по умолчанию: 2017-01-01)
- `Host`: `localhost`
- `Port`: значение `GP_HOST_PORT` из `.env` (по умолчанию `5435`)
- `Database`: значение `GP_DB` из `.env` (по умолчанию `gp_dwh`)
- `Username`: значение `GP_USER` (по умолчанию `gpadmin`)
- `Password`: значение `GP_PASSWORD` (по умолчанию `gpadmin`)
### CSV pipeline
- `CSV_DIR` — путь к каталогу с CSV внутри контейнеров Airflow (по умолчанию: `/opt/airflow/data`)
- `CSV_ROWS` — количество строк, генерируемых DAG (по умолчанию: 1000)
### bookings-db (Postgres, демо-БД `demo`)
### Airflow
- `GP_CONN_ID` — ID подключения (по умолчанию: greenplum_conn)
- `Host`: `localhost`
- `Port`: значение `BOOKINGS_DB_PORT` из `.env` (по умолчанию `5434`)
- `Database`: `demo`
- `Username`: значение `BOOKINGS_DB_USER` (по умолчанию `bookings`)
- `Password`: значение `BOOKINGS_DB_PASSWORD` (по умолчанию `bookings`)
---
## Документация
## 🔍 Продвинутые темы
- [Учебные задания](docs/assignment/README.md)
- [План тестирования/проверок и негативные кейсы](TESTING.md)
- [Дополнительные заметки и технические детали](docs/README.md)
- [Детали по STG DAG](docs/bookings_to_gp_stage.md)
- [Детали по ODS DAG](docs/bookings_to_gp_ods.md)
- [Детали по DDS DAG](docs/bookings_to_gp_dds.md)
- [Детали по DM DAG](docs/bookings_to_gp_dm.md)
### Архитектура pipeline
**Поток данных в DAG `csv_to_greenplum`:**
1. `create_orders_table` — создаёт таблицу `public.orders` в Greenplum
2. `generate_csv` — генерирует датасет при помощи pandas и сохраняет CSV в `CSV_DIR`
3. `preview_csv` — выводит предпросмотр и статистику по данным
4. `load_csv_to_greenplum` — загружает CSV во временную таблицу и переносит новые строки в `public.orders`
> 💡 **Безопасность повторного запуска:** Pipeline защищен от дубликатов, поэтому его можно запускать многократно.
### Проверка качества данных
Запустите DAG `greenplum_data_quality` для автоматической проверки:
- Наличие таблицы в базе
- Соответствие схемы ожидаемой структуре
- Объем загруженных данных
- Отсутствие дубликатов записей
### Ограничения учебного стенда
- **Greenplum** запущен в single-node режиме (для обучения)
- В продакшене Greenplum обычно разворачивают кластером на нескольких серверах
- Используется Greenplum 6 (широко доступная версия), хотя Greenplum 7 предлагает больше возможностей
---
## 🆘 Типичные проблемы и решения
## Типичные проблемы и решения
| Проблема | Решение |
|----------|---------|
| Airflow UI не открывается | Дождитесь сообщения `Listening at: http://0.0.0.0:8080` в логах (`make logs`) |
| Ошибка подключения к Greenplum | Убедитесь, что контейнер `greenplum` стал статусом `healthy` (проверьте `docker compose ps`) |
| Нет файла в `./data` после запуска DAG | Проверьте логи задачи `generate_csv`, убедитесь, что `CSV_DIR` смонтирован в docker-compose |
| Команда `make` не найдена | Используйте полные команды `docker compose` или установите make |
| Greenplum не стартует/падает при старте | Выполните `make down`, затем `make up && make airflow-init` (очищает тома и поднимает заново) |
| `database "demo" does not exist` в bookings‑DAG | Вы сделали reset с удалением volumes (`make clean` / `docker compose down -v`). Запустите `make bookings-init` (быстрое восстановление из дампа, ~18 сек) и повторите DAG. |
| Ошибка `bookings.jobs должен быть >= 1` | Проверьте значение `BOOKINGS_JOBS` в `.env` — оно должно быть целым числом >= 1. По умолчанию `BOOKINGS_JOBS=1` (синхронная генерация). При `BOOKINGS_JOBS>1` генерация идёт параллельно через dblink. |
| Ошибка подключения к Greenplum | Убедитесь, что контейнер `greenplum` имеет статус `healthy` (`docker compose ps`) |
| `protocol "pxf" does not exist` | Перезапустите `greenplum` и повторите `bookings_stg_ddl`/`make ddl-gp` — расширение `pxf` создаётся автоматически при старте контейнера. |
| DAG `bookings_to_gp_stage` ругается на отсутствующие таблицы stg | Запустите `bookings_stg_ddl` (или выполните `make ddl-gp`), затем повторите запуск |
| Порты 8080/5433/5434/5435 заняты | Остановите локальные сервисы или измените порты в `.env` |
---
## Благодарности
## 📁 Структура проекта
```
├── docker-compose.yml # Описание всех сервисов
├── .env.example # Шаблон настроек
├── Makefile # Удобные команды для работы
├── airflow/
│ └── dags/ # Файлы workflow (DAG)
│ ├── csv_to_greenplum.py
│ └── data_quality_greenplum.py
├── bookings/ # Скрипты и вспомогательные файлы для демобазы bookings в Postgres
└── sql/
└── ddl_gp.sql # Создание таблицы в Greenplum
```
---
## 💡 Советы для дальнейшего обучения
1. **Поэкспериментируйте с DAG** — измените параметры генерации данных или размер батча
2. **Добавьте свои проверки** — расширьте DAG `data_quality_greenplum.py`
3. **Попробуйте другие источники** — замените генератор данных на чтение из файла или API
4. **Изучите Airflow deeper** — добавьте зависимости между задачами, настройте расписания
---
## ✅ Тестирование
- Локальные проверки: `make test` (pytest). Для форматирования — `make fmt`, для проверки — `make lint`.
- Пошаговый сценарий с Docker (включая негативные кейсы и reset) — см. `TESTING.md`.
Удачи в изучении Data Engineering! 🚀
- **Postgres Pro** — за демо‑БД bookings и генератор данных `demodb`: https://github.com/postgrespro/demodb (лицензия MIT: https://github.com/postgrespro/demodb/blob/main/LICENSE).
- **woblerr** — за Docker-сборку Greenplum: https://github.com/woblerr/docker-greenplum (образ: `woblerr/greenplum`, лицензия MIT: https://github.com/woblerr/docker-greenplum/blob/master/LICENSE).
+37 -28
View File
@@ -12,60 +12,69 @@
## 2. Локальные автоматические проверки (без Docker)
- `make test` — короткие unit-тесты (`tests/test_greenplum_helpers.py`, `tests/test_dags_smoke.py`).
- Smoke-тесты DAG автоматически `skip`, если Airflow не установлен в venv, поэтому прогонится за миллисекунды.
- `make lint` — black/isort в режиме проверки. Сейчас упадёт из‑за форматирования DAG-файлов.
- `make fmt` — автоисправление форматирования; после этого `make lint` должен пройти.
- `make lint` — black/isort в режиме проверки (после `make fmt` должен проходить без ошибок).
- `make fmt` — автоисправление форматирования; полезно запускать перед пушем.
- (опционально) `uv run pytest -q -k dags_smoke` — только DAG smoke.
## 3. Подготовка Docker-стенда
- `cp .env.example .env` (если файла ещё нет) и проверьте переменные:
- `GP_PORT` не конфликтует с локальным PostgreSQL.
- `GP_PORT` — внутренний порт Greenplum в Docker-сети (по умолчанию 5432, менять не нужно); внешний порт для подключения с хоста фиксирован на `5435`, поэтому локальный PostgreSQL на 5432 не помешает.
- `GP_USE_AIRFLOW_CONN=true` при желании использовать Airflow Connection; `false` — fallback на ENV.
- `make up` — поднимаем все сервисы. Важно дождаться статуса `healthy` у `pgmeta` и `greenplum` (`docker compose ps`).
- `make airflow-init` — миграции мета-БД и создание пользователя Airflow; занимает ~1–2 минуты.
- Если меняли `airflow/requirements.txt` или `Dockerfile.airflow`, выполните `make build` перед `make up`.
- `make up` — поднимаем все сервисы. Важно дождаться статуса `healthy` у `pgmeta` и `greenplum` (`docker compose ps`); `greenplum` считается `healthy` только когда поднят и Greenplum, и PXF.
- `make logs` — следим, пока webserver и scheduler не перейдут в рабочее состояние (`Listening at: http://0.0.0.0:8080`).
## 4. Smoke тесты DAG в Airflow UI
1. Открыть http://localhost:8080 (admin/admin).
2. DAG `csv_to_greenplum`:
- Включить переключатель.
- Нажать «Trigger DAG».
- Контроль: все таски Success, в `data/` появился CSV, в логах `load_csv_to_greenplum` видно `INSERT`.
- В Greenplum (см. п.5) убедиться в наличии строк `(SELECT COUNT(*) ...)`.
3. DAG `greenplum_data_quality`:
- Запустить вручную после первого DAG.
- Проверить, что все 5 задач Success и логи содержат `Проверка пройдена`.
2. (опционально) Зайти в Admin → Connections и убедиться, что DAG’и видят подключения:
- `greenplum_conn` и `bookings_db` задаются через переменные `AIRFLOW_CONN_...` в docker-compose и могут не отображаться в списке, но `airflow connections get greenplum_conn` / `bookings_db` внутри контейнера должны отрабатывать без ошибок.
- DAG `bookings_to_gp_stage` (полная проверка цепочки bookings → Greenplum STG):
- предварительно выполнить один раз: `make bookings-init` (быстрое восстановление демобазы `demo` из seed-дампа, ~18 сек) и `make ddl-gp` (создаёт STG/ODS/DDS слои в Greenplum, включая внешние `*_ext` через PXF);
- перед Trigger проверить, что в source реально есть данные (все значения должны быть `> 0`):
- `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.bookings;"`
- `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.airports_data;"`
- `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.airplanes_data;"`
- если хотя бы один `COUNT(*) = 0`, не запускать DAG: повторить `make bookings-init`; если после этого `bookings.bookings` всё ещё пустая, выполнить `make bookings-generate-day` и снова проверить `COUNT(*)`;
- важно: DAG `bookings_stg_ddl` **не** создаёт базу `demo` в `bookings-db`; если вы делали `docker compose down -v` / `make clean`, `make bookings-init` обязателен (быстрое восстановление из seed-дампа);
- включить DAG `bookings_to_gp_stage` и запустить `Trigger DAG`;
- убедиться, что все задачи завершились со статусом Success (включая загрузки справочников/транзакций и DQ);
- при желании проверить данные: в `bookings-db` появился новый день, а в Greenplum в `stg.bookings` — строки с актуальным `_load_id` (см. пример запросов в разделе 5).
- (опционально, для менторов/разработчиков) Smoke-тест DAG через Airflow CLI без UI:
- `docker compose -f docker-compose.yml exec airflow-webserver airflow dags test bookings_to_gp_stage 2024-01-01` — прогоняет `bookings_to_gp_stage` целиком в «off-line» режиме;
- `docker compose -f docker-compose.yml exec airflow-webserver airflow dags trigger bookings_to_gp_stage` — создаёт реальный запуск DAG (логи и статус можно смотреть либо через UI, либо командой `airflow tasks list`/`airflow tasks logs` внутри контейнера).
## 5. Проверка данных в Greenplum
- `make gp-psql` — запустить psql в контейнере от имени `gpadmin`.
- (опционально) Проверить, что PXF действительно запущен:
- `docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/pxf/bin/pxf cluster status'"`
- Команды внутри psql:
- `\dt public.*` — таблицы схему public.
- `SELECT COUNT(*) FROM public.orders;` — оценка объёма.
- `SELECT * FROM public.orders LIMIT 5;` — визуальная проверка.
- `SELECT order_id FROM public.orders GROUP BY 1 HAVING COUNT(*) > 1;` — поиск дублей.
- (после настройки PXF) `SELECT COUNT(*) FROM public.ext_bookings_bookings;` — проверка чтения из демо-БД bookings через PXF.
- (после настройки PXF) `SELECT * FROM public.ext_bookings_bookings LIMIT 5;` — визуальное сравнение с таблицей `bookings.bookings` в исходной БД.
- Завершить `\q`.
## 6. Негативные сценарии и fallback
- **Пустая таблица**: запустить `greenplum_data_quality` до `csv_to_greenplum`. Ожидается ошибка на таске `check_orders_has_rows`.
- **Проблемы с подключением**: временно изменить `GP_HOST` или `GP_PORT` на несуществующий, перезапустить `make up`, убедиться, что DAG падает с понятной ошибкой (`psycopg2.OperationalError`).
- **Fallback без Airflow Connection**: установить `GP_USE_AIRFLOW_CONN=false`, перезапустить стек (`make down && make up && make airflow-init`), удостовериться, что загрузка и DQ работают через ENV.
- **Дубликаты**: дважды вызвать `csv_to_greenplum` — ожидаем, что количество строк в `public.orders` не увеличится на размер CSV, а DAG `greenplum_data_quality` не найдёт дублей.
- **Fallback без Airflow Connection**: установить `GP_USE_AIRFLOW_CONN=false`, перезапустить стек (`make down && make up`), удостовериться, что загрузка и DQ работают через ENV.
- **PXF и демобаза bookings** (после настройки PXF и выполнения `make ddl-gp`): временно остановить `bookings-db` (`docker compose stop bookings-db`) и попробовать выполнить `SELECT COUNT(*) FROM public.ext_bookings_bookings;` в `make gp-psql` — ожидается ошибка подключения. Затем запустить `bookings-db` (`docker compose start bookings-db`) и убедиться, что запрос снова работает.
## 7. Быстрый reset (если «что-то сломалось»)
- Перезапустить стенд с очисткой данных:
- `make down` — остановит контейнеры и удалит тома.
- `make up && make airflow-init` — заново поднимет всё и проинициализирует Airflow.
- Перезапустить стенд:
- Мягкий вариант (сохранить данные): `make stop`, затем `make up`.
- Полный reset (очистить данные в Docker-томах): `make clean`, затем `make up` (Greenplum/Airflow/bookings будут подняты и инициализированы с нуля).
- Иногда Greenplum не стартует после «грязных» остановок (из‑за старых внутренних файлов). Лечение: всегда делайте `make down` перед повторным `make up`.
## 8. Снятие метрик и мониторинг
- Контейнеры: `docker compose ps`, `docker stats` (по желанию).
- Логи задач: в Airflow UI → конкретный таск → Log.
- Хостовые CSV: каталог `data/` (можно открыть любой файл и убедиться в структуре).
## 9. Завершение работы
- `make down` — выключает сервисы и удаляет тома (перезапишет данные в Greenplum!).
- При необходимости сохранить данные: скопировать CSV из `data/` и дампы из контейнера до `make down`.
- `make down` — выключает сервисы и удаляет контейнеры/сети (volumes сохраняются).
- Полный сброс данных (удаляет volumes): `make clean`.
## Текущий статус (пример успешного прогона)
- `uv run pytest -q` — 11 passed, 2 smoke-теста DAG пропущены (Airflow не установлен в venv).
- `make lint` — падает, потому что `airflow/dags/*.py` не отформатированы black/isort. После `make fmt` проблема уйдёт.
- Docker-стенд не запускался в рамках этой сессии; ожидается, что инструкции выше обеспечат полноценную проверку.
- `uv run pytest -q` — 14 passed, 9 smoke-тестов DAG пропущены (Airflow не установлен в venv).
- `make lint` — проходит (DAG‑файлы отформатированы black/isort).
- Полный ETL-цикл (STG→ODS→DDS→DM) проверен на стенде 2026-03-09: все DAG-и завершились с Success.
+141
View File
@@ -0,0 +1,141 @@
# TODO (maintainers / mentors)
Этот файл собирает задачи по подготовке стенда к курсовой работе
и идеи по доработке, которые не критичны для текущих задач менти.
Контекст и стратегия: [docs/design/PRD.md](docs/design/PRD.md).
Дизайн задания: [docs/design/assignment_design.md](docs/design/assignment_design.md).
---
## Подготовка курсовой
> Дедлайн: ~2-3 недели (первый студент может подойти к курсовой).
### Этап 1. Вынос CSV-пайплайна
**Инструмент:** Sonnet / Gemini / ChatGPT — механическая работа, перенос файлов.
- [x] Перенести в [airflow-manual](https://github.com/dementev-dev/airflow-manual):
`csv_to_greenplum.py`, `csv_to_greenplum_dq.py`, `ddl_greenplum_base.py`,
`helpers/greenplum.py`, `sql/base/orders_ddl.sql`, связанные тесты
- [x] Убрать CSV-зависимости из docker-compose / .env (`CSV_DIR`, `CSV_ROWS`)
- [x] Обновить README (убрать упоминания CSV-пайплайна)
### Этап 1.5. Полировка эталона
**Инструмент:** Opus (глубокий анализ кода и контекста проекта)
+ ручное тестирование (make up, запуск DAG'ов, проверка данных).
- [x] Протестировать полный ETL-цикл с нуля
(make up → bookings-init (восстановление из дампа) → STG → ODS → DDS → DM)
- [x] Прогнать инкремент (bookings-generate-day → повторный запуск DAG'ов)
- [x] Почистить код эталонного среза
- [x] Актуализировать README и документацию
- [x] Убедиться, что `make test` и `make lint` проходят
- [x] Проверить, что стенд поднимается на чистой машине
(проверено 2026-03-09: `cp .env.example .env` → `make up` → `make bookings-init` →
`make ddl-gp` → STG → ODS → DDS → DM — всё success)
### Этап 2. ТЗ от аналитика
**Инструмент:** Opus — нужно глубокое понимание предметной области, маппингов
между слоями, SCD-паттернов и педагогического контекста.
> Делаем пока полный пайплайн работает — можно сверяться с реальными данными.
- [x] Создать `docs/assignment/analyst_spec.md`
- [x] Для каждой таблицы-задания: имя, описание, поля, маппинг,
бизнес-правила, тип SCD, гранулярность, distribution key
- [x] Для dim_routes (SCD2): пошаговый алгоритм текстом, формула hashdiff
- [x] Рекомендуемый порядок выполнения
### Этап 3. Валидационный DAG
**Инструмент:** Sonnet (шаблонная работа, структура в assignment_design.md)
+ Opus для финальной вычитки.
> Делаем и тестируем на полных данных, пока ничего не удалено.
- [x] Создать `airflow/dags/bookings_validate.py`
- [x] Создать SQL-скрипты в `sql/validate/`
- [x] Таски по слоям: STG, ODS, DDS, DM
- [x] Дружелюбные сообщения об ошибках с подсказками
- [x] Протестировать на работающем стенде
### Этап 4. Подготовка main и ветка solution
**Инструмент:** Opus — раскладка по веткам, заглушки, ослабление DQ.
> Финальный этап: всё готово и протестировано, теперь раскладываем по веткам.
> План: `docs/plans/2026-03-12_main-solution-split.md`
- [x] Смержить chore/bookings-etl → main
- [x] Общие правки на main (документация, TODO.md)
- [x] Создать ветку solution (снимок полного эталона)
- [x] Main-only правки: заглушки, ослабление DQ, адаптация тестов
- [x] Очистка docs на main (удалить внутренние документы)
- [x] Верификация main (`make test`, `make lint`)
- [x] Верификация solution (`make test`)
---
## Прочее (бэклог)
- [ ] Протестировать устойчивость `bookings-db` после остановки контейнеров:
- прогнать сценарии `make stop` -> `make up` и `make down` -> `make up`;
- зафиксировать, ломается ли генератор/данные в `bookings-db`;
- при необходимости добавить шаги восстановления и обновить документацию.
---
## Выполнено
- [x] Сделать REST API Airflow основным способом тестирования ETL вместо CLI-вызовов
через `docker compose exec ... airflow ...`:
- обновить `TESTING.md`, сместив фокус на REST API сценарии;
- оставить CLI как резервный вариант для локальной отладки;
- проверить, что шаги тестирования воспроизводимы без входа в контейнер Airflow.
- [x] Собрать свой образ Airflow поверх `apache/airflow:2.9.2`:
- вынести установку Python‑зависимостей из runtime (`pip install ...` при старте контейнеров)
в отдельный `Dockerfile`;
- переключить `docker-compose.yml` на использование этого образа для `airflow-webserver`,
`airflow-scheduler` и `airflow-init`;
- обновить документацию (README/TESTING) под новую схему сборки.
- [x] Переключить Airflow с `SequentialExecutor` (SequentialScheduler) на `LocalExecutor`
для docker‑стенда:
- проверить, какие параметры достаточно поменять в env/конфиге (`AIRFLOW__CORE__EXECUTOR`)
для образа `apache/airflow:2.9.2`;
- убедиться, что примерные DAG'и (`csv_to_greenplum`, `bookings_to_gp_stage`) ведут себя
предсказуемо в режиме параллельного исполнения;
- при необходимости скорректировать тесты и документацию (README/TESTING) с учётом нового executor'а.
- [x] Разобрать и стабилизировать интеграцию с Greenplum/PXF:
- убедиться, что PXF в контейнере `greenplum` всегда корректно инициализируется
(нет ошибок вида `protocol "pxf" does not exist` при первом запуске `make ddl-gp`);
- при необходимости доработать init‑скрипты в `pxf/init/` и/или документацию,
чтобы порядок действий для ментей был однозначным и воспроизводимым;
- добавить краткий раздел в README/TESTING о типичных ошибках PXF/Greenplum и шагах по их устранению.
- диагностика текущего кейса: `docs/reference/pxf_bookings.md` (раздел «Известная проблема»).
- [x] Разобраться с генератором demodb:
- после `make bookings-generate` таблица `bookings.bookings` остаётся пустой;
- патчи `bookings/patches/engine_jobs1_sync.patch` и `bookings/patches/install_drop_if_exists.patch`
падают при применении (hunk failed / garbage in patch);
- из‑за этого DAG `bookings_to_gp_stage` валится на проверках (источник пустой).
- детали: `docs/reference/bookings_db_issues.md`
- [x] Добавить раздел «Благодарности» в `README.md`:
- явно поблагодарить Postgres Pro за демо‑БД bookings (репозиторий `postgrespro/demodb`);
- указать автора Docker‑сборки Greenplum (`woblerr/docker-greenplum`, образ `woblerr/greenplum`);
- при необходимости сослаться на соответствующие лицензии/README исходных проектов.
- [x] Добавить в образ Airflow установку `psql`, чтобы тестировать загрузку CSV из CLI внутри контейнера (без root и дополнительных зависимостей на хосте).
- [x] Денормализовать `dds.dim_routes` (добавить departure_city, arrival_city, airplane_model, total_seats):
- привести измерение в соответствие с принципом Кимбалла («самодостаточное измерение»);
- упростить `dm.route_performance` с 4-JOIN до 1-JOIN;
- обновить DAG-зависимости: airports+airplanes DQ → routes load;
- подробный план: `docs/archive/dim_routes_denormalization_plan.md`.
+1
Submodule _vendor/demodb added at d68de19285.
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
"""
Учебный DAG: создаёт/обновляет слой dds в Greenplum для домена bookings.
Запускается вручную перед DAG загрузки `bookings_to_gp_dds` или после изменения DDS DDL.
Создаёт 7 DDS-таблиц: dim_calendar, dim_airports, dim_airplanes,
dim_tariffs, dim_passengers, dim_routes, fact_flight_sales.
"""
from datetime import timedelta
import pendulum
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow import DAG
GREENPLUM_CONN_ID = "greenplum_conn"
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
with DAG(
dag_id="bookings_dds_ddl",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "greenplum", "ddl", "bookings", "dds"],
description="Учебный DDL DAG: создаёт/обновляет dds.* для bookings",
) as dag:
apply_dds_dim_calendar_ddl = PostgresOperator(
task_id="apply_dds_dim_calendar_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_calendar_ddl.sql",
)
apply_dds_dim_airports_ddl = PostgresOperator(
task_id="apply_dds_dim_airports_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_airports_ddl.sql",
)
apply_dds_dim_airplanes_ddl = PostgresOperator(
task_id="apply_dds_dim_airplanes_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_airplanes_ddl.sql",
)
apply_dds_dim_tariffs_ddl = PostgresOperator(
task_id="apply_dds_dim_tariffs_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_tariffs_ddl.sql",
)
apply_dds_dim_passengers_ddl = PostgresOperator(
task_id="apply_dds_dim_passengers_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_passengers_ddl.sql",
)
apply_dds_dim_routes_ddl = PostgresOperator(
task_id="apply_dds_dim_routes_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_routes_ddl.sql",
)
apply_dds_fact_flight_sales_ddl = PostgresOperator(
task_id="apply_dds_fact_flight_sales_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/fact_flight_sales_ddl.sql",
)
# DDL применяем последовательно, чтобы порядок был понятным для новичков,
# а ошибки — воспроизводимыми (в логах сразу видно, на каком объекте упали).
(
apply_dds_dim_calendar_ddl
>> apply_dds_dim_airports_ddl
>> apply_dds_dim_airplanes_ddl
>> apply_dds_dim_tariffs_ddl
>> apply_dds_dim_passengers_ddl
>> apply_dds_dim_routes_ddl
>> apply_dds_fact_flight_sales_ddl
)
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
"""
Учебный DAG: создаёт/обновляет DM-слой (Data Mart) в Greenplum для домена bookings.
Запускается вручную перед DAG загрузки `bookings_to_gp_dm` или после изменения DM DDL.
Создаёт 5 DM-витрин: sales_report, route_performance, passenger_loyalty,
airport_traffic, monthly_overview.
На данном этапе реализованы все 5 витрин: sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview.
"""
from datetime import timedelta
import pendulum
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow import DAG
GREENPLUM_CONN_ID = "greenplum_conn"
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
with DAG(
dag_id="bookings_dm_ddl",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "greenplum", "ddl", "bookings", "dm"],
description="Учебный DDL DAG: создаёт/обновляет dm.* для bookings",
) as dag:
# Эталонная витрина: sales_report
apply_dm_sales_report_ddl = PostgresOperator(
task_id="apply_dm_sales_report_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/sales_report_ddl.sql",
)
# Витрина: Эффективность маршрутов (Этап 2)
apply_dm_route_performance_ddl = PostgresOperator(
task_id="apply_dm_route_performance_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/route_performance_ddl.sql",
)
# Витрина: Лояльность пассажиров (Этап 3)
apply_dm_passenger_loyalty_ddl = PostgresOperator(
task_id="apply_dm_passenger_loyalty_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/passenger_loyalty_ddl.sql",
)
# Витрина: Пассажиропоток аэропортов (Этап 4)
apply_dm_airport_traffic_ddl = PostgresOperator(
task_id="apply_dm_airport_traffic_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/airport_traffic_ddl.sql",
)
# Витрина: Помесячная сводка (Этап 5)
apply_dm_monthly_overview_ddl = PostgresOperator(
task_id="apply_dm_monthly_overview_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/monthly_overview_ddl.sql",
)
# Линейная цепочка
(
apply_dm_sales_report_ddl
>> apply_dm_route_performance_ddl
>> apply_dm_passenger_loyalty_ddl
>> apply_dm_airport_traffic_ddl
>> apply_dm_monthly_overview_ddl
)
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
"""
Учебный DAG: создаёт/обновляет слой ods в Greenplum для домена bookings.
Запускается вручную перед DAG загрузки `bookings_to_gp_ods` или после изменения ODS DDL.
Создаёт 9 ODS-таблиц: airports, airplanes, routes, seats, bookings, tickets,
flights, segments, boarding_passes.
"""
from datetime import timedelta
import pendulum
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow import DAG
GREENPLUM_CONN_ID = "greenplum_conn"
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
with DAG(
dag_id="bookings_ods_ddl",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "greenplum", "ddl", "bookings", "ods"],
description="Учебный DDL DAG: создаёт/обновляет ods.* для bookings",
) as dag:
apply_ods_airports_ddl = PostgresOperator(
task_id="apply_ods_airports_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/airports_ddl.sql",
)
apply_ods_airplanes_ddl = PostgresOperator(
task_id="apply_ods_airplanes_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/airplanes_ddl.sql",
)
apply_ods_routes_ddl = PostgresOperator(
task_id="apply_ods_routes_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/routes_ddl.sql",
)
apply_ods_seats_ddl = PostgresOperator(
task_id="apply_ods_seats_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/seats_ddl.sql",
)
apply_ods_bookings_ddl = PostgresOperator(
task_id="apply_ods_bookings_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/bookings_ddl.sql",
)
apply_ods_tickets_ddl = PostgresOperator(
task_id="apply_ods_tickets_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/tickets_ddl.sql",
)
apply_ods_flights_ddl = PostgresOperator(
task_id="apply_ods_flights_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/flights_ddl.sql",
)
apply_ods_segments_ddl = PostgresOperator(
task_id="apply_ods_segments_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/segments_ddl.sql",
)
apply_ods_boarding_passes_ddl = PostgresOperator(
task_id="apply_ods_boarding_passes_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/boarding_passes_ddl.sql",
)
# DDL применяем последовательно, чтобы порядок был понятным для новичков,
# а ошибки — воспроизводимыми (в логах сразу видно, на каком объекте упали).
(
apply_ods_airports_ddl
>> apply_ods_airplanes_ddl
>> apply_ods_routes_ddl
>> apply_ods_seats_ddl
>> apply_ods_bookings_ddl
>> apply_ods_tickets_ddl
>> apply_ods_flights_ddl
>> apply_ods_segments_ddl
>> apply_ods_boarding_passes_ddl
)
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
"""
Учебный DAG: создаёт/обновляет слой stg в Greenplum для демо-источника bookings.
Запускается вручную перед DAG загрузки `bookings_to_gp_stage` или после изменения DDL.
Создаёт внешние таблицы PXF (`*_ext`) и внутренние таблицы STG (9 таблиц: bookings, tickets,
airports, airplanes, routes, seats, flights, segments, boarding_passes).
"""
from datetime import timedelta
import pendulum
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow import DAG
GREENPLUM_CONN_ID = "greenplum_conn"
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
with DAG(
dag_id="bookings_stg_ddl",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "greenplum", "ddl", "bookings", "stg"],
description="Учебный DDL DAG: создаёт/обновляет stg.* (PXF external + internal STG) для bookings",
) as dag:
apply_stg_bookings_ddl = PostgresOperator(
task_id="apply_stg_bookings_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/bookings_ddl.sql",
)
apply_stg_tickets_ddl = PostgresOperator(
task_id="apply_stg_tickets_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/tickets_ddl.sql",
)
# DDL для справочников
apply_stg_airports_ddl = PostgresOperator(
task_id="apply_stg_airports_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/airports_ddl.sql",
)
apply_stg_airplanes_ddl = PostgresOperator(
task_id="apply_stg_airplanes_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/airplanes_ddl.sql",
)
apply_stg_routes_ddl = PostgresOperator(
task_id="apply_stg_routes_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/routes_ddl.sql",
)
apply_stg_seats_ddl = PostgresOperator(
task_id="apply_stg_seats_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/seats_ddl.sql",
)
# DDL для транзакционных таблиц
apply_stg_flights_ddl = PostgresOperator(
task_id="apply_stg_flights_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/flights_ddl.sql",
)
apply_stg_segments_ddl = PostgresOperator(
task_id="apply_stg_segments_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/segments_ddl.sql",
)
apply_stg_boarding_passes_ddl = PostgresOperator(
task_id="apply_stg_boarding_passes_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/boarding_passes_ddl.sql",
)
# DDL применяем последовательно, чтобы порядок был понятным для новичков,
# а ошибки — воспроизводимыми (в логах сразу видно, на каком объекте упали).
(
apply_stg_bookings_ddl
>> apply_stg_tickets_ddl
>> apply_stg_airports_ddl
>> apply_stg_airplanes_ddl
>> apply_stg_routes_ddl
>> apply_stg_seats_ddl
>> apply_stg_flights_ddl
>> apply_stg_segments_ddl
>> apply_stg_boarding_passes_ddl
)
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
"""
Учебный DAG: загрузка из ODS в DDS (Greenplum) по домену bookings.
Ключевая идея:
- DDS читает текущее состояние ODS;
- для каждой сущности выполняем пару задач load -> dq;
- для dim_routes применяем SCD2, для остальных измерений — SCD1 UPSERT;
- факт грузим инкрементальным UPSERT по зерну (ticket_no, flight_id).
"""
from datetime import timedelta
from logging import getLogger
import pendulum
from airflow.operators.python import PythonOperator
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow import DAG
GREENPLUM_CONN_ID = "greenplum_conn"
log = getLogger(__name__)
default_args = {
"owner": "airflow",
"retries": 1,
"retry_delay": timedelta(seconds=30),
}
def _finish_summary() -> None:
"""Логирует краткий итог выполнения DDS-ветки."""
log.info("DAG bookings_to_gp_dds завершён. Подробности смотрите в логах задач.")
with DAG(
dag_id="bookings_to_gp_dds",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
max_active_runs=1,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "bookings", "greenplum", "dds"],
description="Учебный DAG: загрузка ODS -> DDS (Star Schema) + DQ проверки",
) as dag:
load_dds_dim_calendar = PostgresOperator(
task_id="load_dds_dim_calendar",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_calendar_load.sql",
)
dq_dds_dim_calendar = PostgresOperator(
task_id="dq_dds_dim_calendar",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_calendar_dq.sql",
)
load_dds_dim_airports = PostgresOperator(
task_id="load_dds_dim_airports",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_airports_load.sql",
)
dq_dds_dim_airports = PostgresOperator(
task_id="dq_dds_dim_airports",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_airports_dq.sql",
)
load_dds_dim_airplanes = PostgresOperator(
task_id="load_dds_dim_airplanes",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_airplanes_load.sql",
)
dq_dds_dim_airplanes = PostgresOperator(
task_id="dq_dds_dim_airplanes",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_airplanes_dq.sql",
)
load_dds_dim_tariffs = PostgresOperator(
task_id="load_dds_dim_tariffs",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_tariffs_load.sql",
)
dq_dds_dim_tariffs = PostgresOperator(
task_id="dq_dds_dim_tariffs",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_tariffs_dq.sql",
)
load_dds_dim_passengers = PostgresOperator(
task_id="load_dds_dim_passengers",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_passengers_load.sql",
)
dq_dds_dim_passengers = PostgresOperator(
task_id="dq_dds_dim_passengers",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_passengers_dq.sql",
)
load_dds_dim_routes = PostgresOperator(
task_id="load_dds_dim_routes",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_routes_load.sql",
)
dq_dds_dim_routes = PostgresOperator(
task_id="dq_dds_dim_routes",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_routes_dq.sql",
)
load_dds_fact_flight_sales = PostgresOperator(
task_id="load_dds_fact_flight_sales",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/fact_flight_sales_load.sql",
)
dq_dds_fact_flight_sales = PostgresOperator(
task_id="dq_dds_fact_flight_sales",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/fact_flight_sales_dq.sql",
)
finish_dds_summary = PythonOperator(
task_id="finish_dds_summary",
python_callable=_finish_summary,
)
load_dds_dim_calendar >> dq_dds_dim_calendar
dq_dds_dim_calendar >> [
load_dds_dim_airports,
load_dds_dim_airplanes,
load_dds_dim_tariffs,
load_dds_dim_passengers,
]
# dim_routes зависит от airports и airplanes (денормализация).
[dq_dds_dim_airports, dq_dds_dim_airplanes] >> load_dds_dim_routes
load_dds_dim_airports >> dq_dds_dim_airports
load_dds_dim_airplanes >> dq_dds_dim_airplanes
load_dds_dim_tariffs >> dq_dds_dim_tariffs
load_dds_dim_passengers >> dq_dds_dim_passengers
load_dds_dim_routes >> dq_dds_dim_routes
[
dq_dds_dim_airports,
dq_dds_dim_airplanes,
dq_dds_dim_tariffs,
dq_dds_dim_passengers,
dq_dds_dim_routes,
] >> load_dds_fact_flight_sales
load_dds_fact_flight_sales >> dq_dds_fact_flight_sales >> finish_dds_summary
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
"""
Учебный DAG: загрузка из DDS в DM (Greenplum) по домену bookings.
Ключевая идея:
- DM читает из DDS (Star Schema);
- для каждой витрины выполняем пару задач load -> dq;
- все витрины загружаются параллельно (не зависят друг от друга);
- sales_report использует UPSERT (heap-таблица), route_performance — Full Rebuild (AO Column).
На данном этапе реализованы все 5 витрин: sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview.
"""
from datetime import timedelta
from logging import getLogger
import pendulum
from airflow.operators.python import PythonOperator
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow import DAG
GREENPLUM_CONN_ID = "greenplum_conn"
log = getLogger(__name__)
default_args = {
"owner": "airflow",
"retries": 1,
"retry_delay": timedelta(seconds=30),
}
def _finish_summary() -> None:
"""Логирует краткий итог выполнения DM-ветки."""
log.info("DAG bookings_to_gp_dm завершён. Подробности смотрите в логах задач.")
with DAG(
dag_id="bookings_to_gp_dm",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
max_active_runs=1,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "bookings", "greenplum", "dm"],
description="Учебный DAG: загрузка DDS -> DM (Data Mart) + DQ проверки",
) as dag:
# start-задача (для структуры, вдруг понадобятся pre-checks)
start_dm = PythonOperator(
task_id="start_dm",
python_callable=lambda: log.info("Начало загрузки DM-слоя..."),
)
# === Эталонная витрина: sales_report ===
load_dm_sales_report = PostgresOperator(
task_id="load_dm_sales_report",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/sales_report_load.sql",
)
dq_dm_sales_report = PostgresOperator(
task_id="dq_dm_sales_report",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/sales_report_dq.sql",
)
# === Витрина: Эффективность маршрутов (Этап 2) ===
load_dm_route_performance = PostgresOperator(
task_id="load_dm_route_performance",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/route_performance_load.sql",
)
dq_dm_route_performance = PostgresOperator(
task_id="dq_dm_route_performance",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/route_performance_dq.sql",
)
# === Витрина: Лояльность пассажиров (Этап 3) ===
load_dm_passenger_loyalty = PostgresOperator(
task_id="load_dm_passenger_loyalty",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/passenger_loyalty_load.sql",
)
dq_dm_passenger_loyalty = PostgresOperator(
task_id="dq_dm_passenger_loyalty",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/passenger_loyalty_dq.sql",
)
# === Витрина: Пассажиропоток аэропортов (Этап 4) ===
load_dm_airport_traffic = PostgresOperator(
task_id="load_dm_airport_traffic",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/airport_traffic_load.sql",
)
dq_dm_airport_traffic = PostgresOperator(
task_id="dq_dm_airport_traffic",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/airport_traffic_dq.sql",
)
# === Витрина: Помесячная сводка (Этап 5) ===
load_dm_monthly_overview = PostgresOperator(
task_id="load_dm_monthly_overview",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/monthly_overview_load.sql",
)
dq_dm_monthly_overview = PostgresOperator(
task_id="dq_dm_monthly_overview",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/monthly_overview_dq.sql",
)
# finish-задача
finish_dm_summary = PythonOperator(
task_id="finish_dm_summary",
python_callable=_finish_summary,
)
# Зависимости: параллельные ветки load -> dq
start_dm >> [
load_dm_sales_report,
load_dm_route_performance,
load_dm_passenger_loyalty,
load_dm_airport_traffic,
load_dm_monthly_overview,
]
# Связываем dq с finish
load_dm_sales_report >> dq_dm_sales_report >> finish_dm_summary
load_dm_route_performance >> dq_dm_route_performance >> finish_dm_summary
load_dm_passenger_loyalty >> dq_dm_passenger_loyalty >> finish_dm_summary
load_dm_airport_traffic >> dq_dm_airport_traffic >> finish_dm_summary
load_dm_monthly_overview >> dq_dm_monthly_overview >> finish_dm_summary
+273
View File
@@ -0,0 +1,273 @@
from __future__ import annotations
"""
Учебный DAG: загрузка из STG в ODS (Greenplum) по домену bookings.
Ключевая идея:
- весь запуск ODS работает с одним stg_batch_id;
- для каждой сущности выполняем пару задач load -> dq;
- загрузка реализована как SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
"""
from datetime import timedelta
from logging import getLogger
import pendulum
from airflow.operators.python import PythonOperator
from airflow.providers.postgres.hooks.postgres import PostgresHook
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow import DAG
GREENPLUM_CONN_ID = "greenplum_conn"
log = getLogger(__name__)
default_args = {
"owner": "airflow",
"retries": 1,
"retry_delay": timedelta(seconds=30),
}
def _resolve_stg_batch_id(**context) -> str:
"""
Возвращает stg_batch_id из dag_run.conf или вычисляет последний согласованный батч.
ВНИМАНИЕ: Это значение используется ТОЛЬКО для загрузки snapshot-справочников
(airports, airplanes, routes, seats).
Для инкрементальных транзакционных таблиц (bookings, tickets, flights, segments,
boarding_passes) этот батч НЕ используется. Вместо этого они грузят все новые
записи по HWM: WHERE _load_ts > (SELECT MAX(_load_ts) FROM ods.table).
Это сделано для того, чтобы не потерять инкременты, если STG-DAG запускался
несколько раз до запуска ODS-DAG'а.
Зачем нужна согласованность (INTERSECT по всем справочникам)?
Чтобы ODS загружал только те данные, для которых уже приехали ВСЕ связанные
справочники. Это защищает от рассинхрона данных, когда часть измерений в текущем
батче обновилась, а часть — упала или не доехала, что могло бы привести к
потере ссылочной целостности при сборке витрин.
"""
conf = context["dag_run"].conf or {}
stg_batch_id = conf.get("stg_batch_id")
if not stg_batch_id:
hook = PostgresHook(postgres_conn_id=GREENPLUM_CONN_ID)
result = hook.get_first(
"""
WITH candidate_batches AS (
SELECT _load_id
FROM stg.airports
WHERE _load_id IS NOT NULL AND _load_id <> ''
GROUP BY _load_id
INTERSECT
SELECT _load_id
FROM stg.airplanes
WHERE _load_id IS NOT NULL AND _load_id <> ''
GROUP BY _load_id
INTERSECT
SELECT _load_id
FROM stg.routes
WHERE _load_id IS NOT NULL AND _load_id <> ''
GROUP BY _load_id
INTERSECT
SELECT _load_id
FROM stg.seats
WHERE _load_id IS NOT NULL AND _load_id <> ''
GROUP BY _load_id
),
batch_ready AS (
SELECT
c._load_id,
GREATEST(
COALESCE(
(SELECT MAX(_load_ts) FROM stg.airports a WHERE a._load_id = c._load_id),
TIMESTAMP '1900-01-01 00:00:00'
),
COALESCE(
(SELECT MAX(_load_ts) FROM stg.airplanes a WHERE a._load_id = c._load_id),
TIMESTAMP '1900-01-01 00:00:00'
),
COALESCE(
(SELECT MAX(_load_ts) FROM stg.routes r WHERE r._load_id = c._load_id),
TIMESTAMP '1900-01-01 00:00:00'
),
COALESCE(
(SELECT MAX(_load_ts) FROM stg.seats s WHERE s._load_id = c._load_id),
TIMESTAMP '1900-01-01 00:00:00'
)
) AS ready_dttm
FROM candidate_batches c
)
SELECT _load_id
FROM batch_ready
ORDER BY ready_dttm DESC
LIMIT 1
"""
)
stg_batch_id = result[0] if result and result[0] else None
if not stg_batch_id:
raise ValueError(
"stg_batch_id не найден: передайте stg_batch_id в conf или "
"сначала выполните bookings_to_gp_stage для snapshot-справочников"
)
log.info("Используем stg_batch_id=%s", stg_batch_id)
return stg_batch_id
def _finish_summary() -> None:
"""Логирует краткий итог выполнения ODS-ветки."""
log.info("DAG bookings_to_gp_ods завершён. Подробности смотрите в логах задач.")
with DAG(
dag_id="bookings_to_gp_ods",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
max_active_runs=1,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "bookings", "greenplum", "ods"],
description="Учебный DAG: загрузка STG -> ODS (SCD1 UPSERT) + DQ проверки",
) as dag:
resolve_stg_batch_id = PythonOperator(
task_id="resolve_stg_batch_id",
python_callable=_resolve_stg_batch_id,
)
load_ods_bookings = PostgresOperator(
task_id="load_ods_bookings",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/bookings_load.sql",
)
dq_ods_bookings = PostgresOperator(
task_id="dq_ods_bookings",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/bookings_dq.sql",
)
load_ods_tickets = PostgresOperator(
task_id="load_ods_tickets",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/tickets_load.sql",
)
dq_ods_tickets = PostgresOperator(
task_id="dq_ods_tickets",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/tickets_dq.sql",
)
load_ods_airports = PostgresOperator(
task_id="load_ods_airports",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/airports_load.sql",
)
dq_ods_airports = PostgresOperator(
task_id="dq_ods_airports",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/airports_dq.sql",
)
load_ods_airplanes = PostgresOperator(
task_id="load_ods_airplanes",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/airplanes_load.sql",
)
dq_ods_airplanes = PostgresOperator(
task_id="dq_ods_airplanes",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/airplanes_dq.sql",
)
load_ods_routes = PostgresOperator(
task_id="load_ods_routes",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/routes_load.sql",
)
dq_ods_routes = PostgresOperator(
task_id="dq_ods_routes",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/routes_dq.sql",
)
load_ods_seats = PostgresOperator(
task_id="load_ods_seats",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/seats_load.sql",
)
dq_ods_seats = PostgresOperator(
task_id="dq_ods_seats",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/seats_dq.sql",
)
load_ods_flights = PostgresOperator(
task_id="load_ods_flights",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/flights_load.sql",
)
dq_ods_flights = PostgresOperator(
task_id="dq_ods_flights",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/flights_dq.sql",
)
load_ods_segments = PostgresOperator(
task_id="load_ods_segments",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/segments_load.sql",
)
dq_ods_segments = PostgresOperator(
task_id="dq_ods_segments",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/segments_dq.sql",
)
load_ods_boarding_passes = PostgresOperator(
task_id="load_ods_boarding_passes",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/boarding_passes_load.sql",
)
dq_ods_boarding_passes = PostgresOperator(
task_id="dq_ods_boarding_passes",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="ods/boarding_passes_dq.sql",
)
finish_ods_summary = PythonOperator(
task_id="finish_ods_summary",
python_callable=_finish_summary,
)
# stg_batch_id нужен всем загрузочным веткам.
(
resolve_stg_batch_id
>> load_ods_bookings
>> dq_ods_bookings
>> load_ods_tickets
>> dq_ods_tickets
)
resolve_stg_batch_id >> load_ods_airports >> dq_ods_airports
resolve_stg_batch_id >> load_ods_airplanes >> dq_ods_airplanes
[dq_ods_airports, dq_ods_airplanes] >> load_ods_routes >> dq_ods_routes
dq_ods_airplanes >> load_ods_seats >> dq_ods_seats
dq_ods_routes >> load_ods_flights >> dq_ods_flights
[dq_ods_flights, dq_ods_tickets] >> load_ods_segments >> dq_ods_segments
dq_ods_segments >> load_ods_boarding_passes >> dq_ods_boarding_passes
[dq_ods_boarding_passes, dq_ods_seats] >> finish_ods_summary
+234
View File
@@ -0,0 +1,234 @@
from __future__ import annotations
"""
Учебный DAG для менти: показывает, как устроен поток
от источника bookings-db до слоя stg в Greenplum.
В этом примере мы сознательно используем:
- Airflow Connections для подключения к БД;
- PostgresOperator, который берёт SQL-скрипты с диска;
чтобы студент увидел «канонический» способ работы с SQL в DAG.
Каждый запуск DAG работает как «шаг по времени вперёд»:
- генератор в демо-БД bookings добавляет следующий учебный день после max(book_date);
- загрузка в Greenplum берёт все строки, появившиеся после предыдущих батчей;
- `run_id` используется как метка запуска (в `_load_id`, в логах и DQ).
Важно: для инкрементальных таблиц «пустое окно инкремента» допустимо (это не ошибка).
Граф зависимостей (параллельный там, где данные независимы):
generate_bookings_day → load_bookings → check_bookings_dq
→ load_tickets → check_tickets_dq
├─ load_airports → check_airports_dq ─┐
│ ├─ load_routes → check_routes_dq
├─ load_airplanes → check_airplanes_dq ┤ → load_flights → check_flights_dq
│ │ → load_segments → check_segments_dq
│ │ → load_boarding_passes → check_bp_dq ─┐
│ └─ load_seats → check_seats_dq ──────────────────────┤
│ ▼
└──────────────────────────────────────────────────────────────────────────── finish_summary
airports и airplanes грузятся параллельно (они не зависят друг от друга).
routes зависит от обоих (DQ проверяет ссылочную целостность на airports и airplanes).
seats зависит только от airplanes (DQ проверяет airplane_code → airplanes).
"""
from datetime import timedelta
import pendulum
from airflow.operators.python import PythonOperator
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow import DAG
default_args = {
"owner": "airflow",
"retries": 1,
"retry_delay": timedelta(seconds=30),
}
BOOKINGS_CONN_ID = "bookings_db"
GREENPLUM_CONN_ID = "greenplum_conn"
def _finish_summary() -> None:
"""
Логирует краткий итог выполнения DAG за один запуск.
Здесь можно добавить дополнительную агрегацию/логирование,
но для учебного примера достаточно простого сообщения.
"""
from logging import getLogger
log = getLogger(__name__)
log.info("DAG bookings_to_gp_stage завершён. Подробности смотрите в логах задач.")
with DAG(
dag_id="bookings_to_gp_stage",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
max_active_runs=1,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "bookings", "greenplum", "stg"],
description="Учебный DAG: загрузка из bookings-db в слой stg (Greenplum) + DQ проверки",
) as dag:
# 1. Генерируем один (или несколько стартовых) учебный день в демо-БД bookings
generate_bookings_day = PostgresOperator(
task_id="generate_bookings_day",
postgres_conn_id=BOOKINGS_CONN_ID,
sql="src/bookings_generate_day_if_missing.sql",
autocommit=True,
)
# 2. Загружаем инкремент из stg.bookings_ext в stg.bookings
load_bookings_to_stg = PostgresOperator(
task_id="load_bookings_to_stg",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/bookings_load.sql",
)
# 3. Проверяем количество строк между источником и stg.bookings
check_row_counts = PostgresOperator(
task_id="check_row_counts",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/bookings_dq.sql",
)
# 4. Загружаем инкремент билетов из stg.tickets_ext в stg.tickets
load_tickets_to_stg = PostgresOperator(
task_id="load_tickets_to_stg",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/tickets_load.sql",
)
# 5. Проверяем качество данных для tickets
check_tickets_dq = PostgresOperator(
task_id="check_tickets_dq",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/tickets_dq.sql",
)
# Загрузка справочников (full load)
load_airports_to_stg = PostgresOperator(
task_id="load_airports_to_stg",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/airports_load.sql",
)
check_airports_dq = PostgresOperator(
task_id="check_airports_dq",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/airports_dq.sql",
)
load_airplanes_to_stg = PostgresOperator(
task_id="load_airplanes_to_stg",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/airplanes_load.sql",
)
check_airplanes_dq = PostgresOperator(
task_id="check_airplanes_dq",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/airplanes_dq.sql",
)
load_routes_to_stg = PostgresOperator(
task_id="load_routes_to_stg",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/routes_load.sql",
)
check_routes_dq = PostgresOperator(
task_id="check_routes_dq",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/routes_dq.sql",
)
load_seats_to_stg = PostgresOperator(
task_id="load_seats_to_stg",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/seats_load.sql",
)
check_seats_dq = PostgresOperator(
task_id="check_seats_dq",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/seats_dq.sql",
)
# Загрузка транзакций (инкремент)
load_flights_to_stg = PostgresOperator(
task_id="load_flights_to_stg",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/flights_load.sql",
)
check_flights_dq = PostgresOperator(
task_id="check_flights_dq",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/flights_dq.sql",
)
load_segments_to_stg = PostgresOperator(
task_id="load_segments_to_stg",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/segments_load.sql",
)
check_segments_dq = PostgresOperator(
task_id="check_segments_dq",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/segments_dq.sql",
)
load_boarding_passes_to_stg = PostgresOperator(
task_id="load_boarding_passes_to_stg",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/boarding_passes_load.sql",
)
check_boarding_passes_dq = PostgresOperator(
task_id="check_boarding_passes_dq",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/boarding_passes_dq.sql",
)
# Финальный лог/сводка
finish_summary = PythonOperator(
task_id="finish_summary",
python_callable=_finish_summary,
)
# === Этап 1. Транзакции: bookings → tickets (последовательно, т.к. tickets зависят от bookings) ===
generate_bookings_day >> load_bookings_to_stg >> check_row_counts
check_row_counts >> load_tickets_to_stg >> check_tickets_dq
# === Этап 2. Справочники (параллельно, где данные независимы) ===
# airports и airplanes не зависят друг от друга — грузим параллельно.
check_tickets_dq >> load_airports_to_stg >> check_airports_dq
check_tickets_dq >> load_airplanes_to_stg >> check_airplanes_dq
# routes зависит от airports И airplanes (DQ проверяет ссылочную целостность).
[check_airports_dq, check_airplanes_dq] >> load_routes_to_stg >> check_routes_dq
# seats зависит только от airplanes (DQ проверяет ссылочную целостность).
check_airplanes_dq >> load_seats_to_stg >> check_seats_dq
# === Этап 3. Транзакции (последовательно, каждая зависит от предыдущей) ===
# flights зависят от routes (DQ проверяет ссылочную целостность route_no → routes).
check_routes_dq >> load_flights_to_stg >> check_flights_dq
# segments зависят от flights и tickets (DQ проверяет обе ссылки).
check_flights_dq >> load_segments_to_stg >> check_segments_dq
# boarding_passes зависят от segments и tickets (DQ проверяет обе ссылки).
check_segments_dq >> load_boarding_passes_to_stg >> check_boarding_passes_dq
# === Финал: ждём завершения ВСЕХ веток ===
[check_boarding_passes_dq, check_seats_dq] >> finish_summary
+150
View File
@@ -0,0 +1,150 @@
"""
Валидационный DAG: самопроверка студенческих заданий.
Запускается вручную в Airflow UI после реализации заданий.
Таски сгруппированы по слоям — студент видит, где именно проблема.
Структура:
validate_ods — проверки ODS: rowcount, дубли BK, NULL в PK
validate_dds — проверки DDS: SCD1-измерения, SCD2 dim_routes (активный тест!)
validate_dm — проверки DM-витрин: не пусты, нет NULL в ключах
Все три группы запускаются параллельно — инкрементальная обратная связь:
студент может проверить только ODS, пока DDS/DM ещё не реализованы.
"""
from datetime import timedelta
from logging import getLogger
import pendulum
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow.utils.task_group import TaskGroup
from airflow import DAG
GREENPLUM_CONN_ID = "greenplum_conn"
log = getLogger(__name__)
default_args = {
"owner": "airflow",
"retries": 0, # Без ретраев — студент должен увидеть ошибку сразу
"retry_delay": timedelta(seconds=10),
}
with DAG(
dag_id="bookings_validate",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None, # Только ручной запуск
catchup=False,
max_active_runs=1,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "bookings", "greenplum", "validate"],
description="Валидация студенческих заданий: ODS, DDS, DM",
) as dag:
with TaskGroup("validate_ods") as validate_ods:
check_ods_airplanes = PostgresOperator(
task_id="check_ods_airplanes_rowcount",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_airplanes_rowcount.sql",
)
check_ods_seats = PostgresOperator(
task_id="check_ods_seats_rowcount",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_seats_rowcount.sql",
)
check_ods_dup_bk = PostgresOperator(
task_id="check_ods_no_dup_bk",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_no_dup_bk.sql",
)
check_ods_pks = PostgresOperator(
task_id="check_ods_no_null_pks",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_no_null_pks.sql",
)
with TaskGroup("validate_dds") as validate_dds:
check_dim_airplanes = PostgresOperator(
task_id="check_dim_airplanes_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_airplanes_exists.sql",
)
check_dim_passengers = PostgresOperator(
task_id="check_dim_passengers_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_passengers_exists.sql",
)
check_dim_passengers_dup = PostgresOperator(
task_id="check_dim_passengers_no_dup_bk",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_passengers_no_dup_bk.sql",
)
check_dim_routes = PostgresOperator(
task_id="check_dim_routes_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_exists.sql",
)
# SCD2 активный тест: цепочка backup → mutate → load → check → restore
scd2_backup = PostgresOperator(
task_id="scd2_backup",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_backup.sql",
)
scd2_mutate = PostgresOperator(
task_id="scd2_mutate",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_mutate.sql",
)
scd2_run_load = PostgresOperator(
task_id="scd2_run_student_load",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_routes_load.sql", # студенческий load-скрипт!
)
scd2_check = PostgresOperator(
task_id="scd2_check",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_check.sql",
)
scd2_restore = PostgresOperator(
task_id="scd2_restore",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_restore.sql",
trigger_rule="all_done", # Откат ВСЕГДА, даже если check упал
)
check_dim_routes_gaps = PostgresOperator(
task_id="check_dim_routes_no_gaps",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_no_gaps.sql",
)
# SCD2 цепочка
scd2_backup >> scd2_mutate >> scd2_run_load >> scd2_check >> scd2_restore
# no_gaps запускается после restore (на чистых данных)
scd2_restore >> check_dim_routes_gaps
with TaskGroup("validate_dm") as validate_dm:
check_airport_traffic = PostgresOperator(
task_id="check_airport_traffic_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/airport_traffic_exists.sql",
)
check_route_performance = PostgresOperator(
task_id="check_route_performance_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/route_performance_exists.sql",
)
check_monthly_overview = PostgresOperator(
task_id="check_monthly_overview_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/monthly_overview_exists.sql",
)
check_passenger_loyalty = PostgresOperator(
task_id="check_passenger_loyalty_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/passenger_loyalty_exists.sql",
)
-167
View File
@@ -1,167 +0,0 @@
from __future__ import annotations
import logging
import os
import random
from datetime import datetime, timedelta
from pathlib import Path
from typing import List
import pandas as pd
from airflow.operators.python import PythonOperator
from helpers.greenplum import get_gp_conn
from airflow import DAG
CSV_DIR = Path(os.getenv("CSV_DIR", "/opt/airflow/data"))
CSV_ROWS = int(os.getenv("CSV_ROWS", "1000"))
def _create_table() -> None:
"""Создаёт таблицу public.orders, если она ещё не существует."""
ddl = """
CREATE TABLE IF NOT EXISTS public.orders (
order_id BIGINT,
order_ts TIMESTAMP NOT NULL,
customer_id BIGINT NOT NULL,
amount NUMERIC(12,2) NOT NULL
)
WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1)
DISTRIBUTED BY (order_id);
"""
with get_gp_conn() as conn, conn.cursor() as cur:
cur.execute(ddl)
conn.commit()
def _generate_csv(rows: int, csv_dir: Path) -> str:
"""Генерирует CSV c заказами с помощью pandas и сохраняет на диск."""
csv_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
csv_path = csv_dir / f"orders_{timestamp}.csv"
# Генерируем данные в pandas-стиле
base_order_id = int(datetime.utcnow().timestamp() * 1_000)
# Создаём DataFrame с использованием pandas методов
df = pd.DataFrame(
{
# Уникальные order_id начиная с базового значения
"order_id": pd.Series(
range(base_order_id, base_order_id + rows), dtype="int64"
),
# Временные метки с интервалом в 1 секунду в обратном порядке
"order_ts": pd.date_range(
end=datetime.utcnow(), periods=rows, freq="1S"
).sort_values(ascending=False),
# Случайные customer_id от 1 до 1000
"customer_id": pd.Series(
random.choices(range(1, 1001), k=rows), dtype="int64"
),
# Случайные суммы от 10 до 500 с округлением до 2 знаков
"amount": pd.Series(
[round(random.uniform(10, 500), 2) for _ in range(rows)],
dtype="float64",
),
}
)
# Сохраняем CSV без индекса
df.to_csv(csv_path, index=False)
logging.info("CSV сохранён: %s (строк: %s)", csv_path, len(df))
return str(csv_path)
def _preview_csv(csv_path: str, sample_rows: int = 5) -> None:
"""Отображает предпросмотр CSV через pandas (head и describe)."""
df = pd.read_csv(csv_path)
df["order_ts"] = pd.to_datetime(df["order_ts"], errors="coerce")
logging.info(
"Первые %s строк:\n%s", sample_rows, df.head(sample_rows).to_string(index=False)
)
numeric_summary = df.describe(include="number")
logging.info("Числовая статистика:\n%s", numeric_summary.to_string())
if df["order_ts"].notna().any():
logging.info(
"Диапазон order_ts: %s → %s",
df["order_ts"].min().isoformat(),
df["order_ts"].max().isoformat(),
)
def _load_csv(csv_path: str) -> None:
"""Загружает CSV в Greenplum через временную таблицу и anti-join."""
csv_file = Path(csv_path)
if not csv_file.exists():
raise FileNotFoundError(f"CSV не найден: {csv_file}")
with (
get_gp_conn() as conn,
conn.cursor() as cur,
csv_file.open("r", encoding="utf-8") as f,
):
cur.execute(
"CREATE TEMP TABLE tmp_orders (LIKE public.orders INCLUDING DEFAULTS) ON COMMIT DROP;"
)
cur.copy_expert(
"COPY tmp_orders (order_id, order_ts, customer_id, amount) FROM STDIN WITH CSV HEADER",
f,
)
cur.execute("SELECT COUNT(*) FROM tmp_orders")
tmp_rows = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO public.orders(order_id, order_ts, customer_id, amount)
SELECT t.order_id, t.order_ts, t.customer_id, t.amount
FROM tmp_orders t
LEFT JOIN public.orders o ON o.order_id = t.order_id
WHERE o.order_id IS NULL
"""
)
inserted = cur.rowcount if cur.rowcount != -1 else 0
conn.commit()
logging.info("Загружено строк: %s (прочитано из CSV: %s)", inserted, tmp_rows)
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
with DAG(
dag_id="csv_to_greenplum",
start_date=datetime(2024, 1, 1),
schedule=None,
catchup=False,
default_args=default_args,
tags=["demo", "greenplum", "csv"],
) as dag:
create_table = PythonOperator(
task_id="create_orders_table",
python_callable=_create_table,
)
generate_csv = PythonOperator(
task_id="generate_csv",
python_callable=_generate_csv,
op_kwargs={"rows": CSV_ROWS, "csv_dir": CSV_DIR},
)
preview_csv = PythonOperator(
task_id="preview_csv",
python_callable=_preview_csv,
op_kwargs={
"csv_path": "{{ ti.xcom_pull(task_ids='generate_csv') }}",
"sample_rows": 5,
},
)
load_csv = PythonOperator(
task_id="load_csv_to_greenplum",
python_callable=_load_csv,
op_kwargs={
"csv_path": "{{ ti.xcom_pull(task_ids='generate_csv') }}",
},
)
create_table >> generate_csv >> preview_csv >> load_csv
-93
View File
@@ -1,93 +0,0 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from airflow.operators.python import PythonOperator
from helpers.greenplum import (assert_orders_have_rows,
assert_orders_no_duplicates,
assert_orders_schema,
assert_orders_table_exists, get_gp_conn)
from airflow import DAG
def _run_check(check_callable):
"""
Оборачивает проверку качества данных в контекст подключения к Greenplum.
Этот DAG предназначен для автоматической проверки качества данных в таблице orders:
1. Проверяет существование таблицы
2. Проверяет соответствие схемы
3. Проверяет наличие данных
4. Проверяет отсутствие дубликатов
Args:
check_callable: Функция проверки, принимающая подключение к БД
"""
# Получаем имя функции для логов
check_name = check_callable.__name__.replace("assert_", "")
logging.info("🚀 Запуск проверки: %s", check_name)
with get_gp_conn() as conn:
check_callable(conn)
logging.info("✅ Проверка пройдена: %s", check_name)
def _log_dq_summary():
"""
Логирует итоговую сводку по качеству данных.
Эта задача выполняется после всех проверок и показывает общий результат.
"""
logging.info("🎉 Все проверки качества данных пройдены успешно!")
logging.info("📊 Качество данных в таблице orders соответствует требованиям.")
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
with DAG(
dag_id="greenplum_data_quality",
start_date=datetime(2024, 1, 1),
schedule=None,
catchup=False,
default_args=default_args,
tags=["demo", "greenplum", "quality"],
description="Автоматизированные проверки качества данных в Greenplum",
) as dag:
# Задача 1: Проверка существования таблицы
check_exists = PythonOperator(
task_id="check_orders_table_exists",
python_callable=_run_check,
op_args=[assert_orders_table_exists],
)
# Задача 2: Проверка соответствия схемы таблицы
check_schema = PythonOperator(
task_id="check_orders_schema",
python_callable=_run_check,
op_args=[assert_orders_schema],
)
# Задача 3: Проверка наличия данных
check_has_rows = PythonOperator(
task_id="check_orders_has_rows",
python_callable=_run_check,
op_args=[assert_orders_have_rows],
)
# Задача 4: Проверка отсутствия дубликатов
check_no_duplicates = PythonOperator(
task_id="check_order_duplicates",
python_callable=_run_check,
op_args=[assert_orders_no_duplicates],
)
# Задача 5: Итоговая сводка
dq_summary = PythonOperator(
task_id="data_quality_summary",
python_callable=_log_dq_summary,
)
# Определяем последовательность выполнения задач
check_exists >> check_schema >> check_has_rows >> check_no_duplicates >> dq_summary
-213
View File
@@ -1,213 +0,0 @@
from __future__ import annotations
import logging
import os
from typing import List, Sequence, Tuple
import psycopg2
# Настройки для подключения к Greenplum. По умолчанию используем Airflow Connection,
# но при проблемах можно переключиться на ENV-подключение, установив GP_USE_AIRFLOW_CONN=false.
GP_CONN_ID = os.getenv("GP_CONN_ID", "greenplum_conn")
GP_USE_AIRFLOW_CONN = os.getenv("GP_USE_AIRFLOW_CONN", "true").lower() in (
"1",
"true",
"yes",
)
# Ожидаемая схема таблицы orders для проверки качества данных
EXPECTED_ORDERS_SCHEMA: List[Tuple[str, str]] = [
("order_id", "bigint"),
("order_ts", "timestamp without time zone"),
("customer_id", "bigint"),
("amount", "numeric"),
]
def get_gp_conn():
"""
Возвращает psycopg2 connection к Greenplum.
Приоритет подключения:
1. Через Airflow Connection (если настроено и доступно)
2. Прямое подключение по переменным окружения (фоллбек)
Returns:
psycopg2 connection object
"""
if GP_USE_AIRFLOW_CONN:
try:
from airflow.providers.postgres.hooks.postgres import PostgresHook
hook = PostgresHook(postgres_conn_id=GP_CONN_ID)
conn = hook.get_conn()
logging.info("✅ Подключение через Airflow Connection успешно")
return conn
except Exception as e:
logging.warning("⚠️ Не удалось подключиться через Airflow Connection: %s", e)
logging.info("🔄 Переключаемся на прямое подключение по ENV переменным")
# Фоллбек на прямое подключение по переменным окружения.
# Прямое подключение по переменным окружения
conn_params = {
"dbname": os.getenv("GP_DB", "gpadmin"),
"user": os.getenv("GP_USER", "gpadmin"),
"password": os.getenv("GP_PASSWORD", ""),
"host": os.getenv("GP_HOST", "greenplum"),
"port": int(os.getenv("GP_PORT", "5432")),
}
logging.info(
"🔗 Подключение к Greenplum: %s:%s", conn_params["host"], conn_params["port"]
)
return psycopg2.connect(**conn_params)
def assert_orders_table_exists(conn) -> None:
"""
Проверяет наличие таблицы orders в схеме public.
Args:
conn: Подключение к Greenplum
Raises:
ValueError: Если таблица не найдена
"""
logging.info("🔍 Проверяем существование таблицы public.orders...")
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1
FROM pg_catalog.pg_tables
WHERE schemaname = 'public' AND tablename = 'orders'
"""
)
if cur.fetchone() is None:
raise ValueError(
"❌ Таблица public.orders не найдена; запусти DAG csv_to_greenplum."
)
logging.info("✅ Таблица public.orders существует")
def fetch_orders_schema(conn) -> Sequence[Tuple[str, str]]:
"""
Получает схему таблицы orders из information_schema.
Args:
conn: Подключение к Greenplum
Returns:
Список кортежей (имя_колонки, тип_данных)
"""
with conn.cursor() as cur:
cur.execute(
"""
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'orders'
ORDER BY ordinal_position
"""
)
return cur.fetchall()
def assert_orders_schema(conn) -> None:
"""
Проверяет, что схема таблицы orders соответствует ожидаемой.
Args:
conn: Подключение к Greenplum
Raises:
ValueError: Если схема не соответствует ожидаемой
"""
logging.info("📋 Проверяем схему таблицы orders...")
schema = fetch_orders_schema(conn)
logging.info("📊 Фактическая схема: %s", list(schema))
logging.info("📊 Ожидаемая схема: %s", EXPECTED_ORDERS_SCHEMA)
if list(schema) != EXPECTED_ORDERS_SCHEMA:
raise ValueError(
f"❌ Неожиданная схема orders: {schema}. Ожидали {EXPECTED_ORDERS_SCHEMA}."
)
logging.info("✅ Схема таблицы orders соответствует ожиданиям")
def fetch_orders_count(conn) -> int:
"""
Получает количество строк в таблице orders.
Args:
conn: Подключение к Greenplum
Returns:
Количество строк в таблице
"""
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM public.orders")
return cur.fetchone()[0]
def assert_orders_have_rows(conn) -> None:
"""
Проверяет, что таблица orders не пустая.
Args:
conn: Подключение к Greenplum
Raises:
ValueError: Если таблица пустая
"""
logging.info("📊 Проверяем наличие данных в таблице orders...")
row_count = fetch_orders_count(conn)
logging.info("📈 Количество строк в orders: %s", row_count)
if row_count <= 0:
raise ValueError(
"❌ Таблица public.orders пустая — запусти DAG csv_to_greenplum перед проверкой."
)
logging.info("✅ Таблица orders содержит данные (%s строк)", row_count)
def fetch_orders_duplicates(conn) -> int:
"""
Подсчитывает количество дубликатов по order_id.
Args:
conn: Подключение к Greenplum
Returns:
Количество дублирующихся order_id
"""
with conn.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) FROM (
SELECT order_id
FROM public.orders
GROUP BY order_id
HAVING COUNT(*) > 1
) d
"""
)
return cur.fetchone()[0]
def assert_orders_no_duplicates(conn) -> None:
"""
Проверяет, что в таблице нет дублей по order_id.
Args:
conn: Подключение к Greenplum
Raises:
ValueError: Если обнаружены дубликаты
"""
logging.info("🔍 Проверяем отсутствие дубликатов по order_id...")
duplicates = fetch_orders_duplicates(conn)
logging.info("📊 Найдено дубликатов: %s", duplicates)
if duplicates:
raise ValueError(
f"❌ Обнаружены дубли по order_id ({duplicates} шт.) — проверь загрузку данных."
)
logging.info("✅ Дубликаты не обнаружены")
+42 -2
View File
@@ -4,8 +4,48 @@
На первом этапе мы:
- поднимаем отдельный контейнер `bookings-db` с Postgres;
- устанавливаем в нём генератор демобазы `demodb` (репозиторий `postgrespro/demodb`);
- инициализируем демобазу одним из двух способов (см. ниже);
- генерируем данные «день за днём» с помощью `make`‑команд.
Основные команды см. в корневом `Makefile` (`bookings-init`, `bookings-generate-day`, `bookings-psql`) и в `README.md` проекта.
### Два способа инициализации
| Команда | Что делает | Время | Для кого |
|---------|-----------|-------|----------|
| `make bookings-init` | Быстрое восстановление из seed-дампа | ~18 сек | **Студенты** (рекомендуется по умолчанию) |
| `make bookings-generate` | Полная генерация с нуля через генератор demodb | часы | Разработчики, пересоздание дампа |
Основные команды см. в корневом `Makefile` (`bookings-init`, `bookings-generate`, `bookings-generate-day`, `bookings-psql`) и в `README.md` проекта.
## Источник и версия
- Репозиторий демобазы: `postgrespro/demodb`.
- Закреплённый коммит: `866e56f7` (см. `DEMODB_COMMIT` в корневом `Makefile`).
## Что мы патчим в demodb
- `install.sql`: `DROP DATABASE IF EXISTS demo WITH (FORCE)` — установка не падает, даже если демобазу держат активные сессии (например, из Airflow).
- `engine.sql`: два изменения в `engine_jobs1_sync.patch`:
- `busy()` игнорирует свой `pid`, чтобы не считать собственное подключение занятым;
- `continue()` при `jobs=1` вызывает `process_queue` синхронно (без `dblink`), иначе генерация обрывается при выходе из `psql` и данных не появляется.
- `install.sql`: удалён хардкод `gen.connstr` без credentials (`install_connstr_no_hardcode.patch`).
- Дефолт: `BOOKINGS_JOBS=1` (синхронно, без dblink — оптимально для +1 дня, ~3 мин). При `jobs>1` — через dblink, но на WSL2 в 3× медленнее из-за lock contention на `gen.events`.
- Патчи применяются автоматически в `make bookings-generate` (генерация с нуля). Если что-то пошло не так, их можно накатить вручную:
```
patch -d bookings/demodb -p1 --forward < bookings/patches/install_drop_if_exists.patch
patch -d bookings/demodb -p1 --forward < bookings/patches/engine_jobs1_sync.patch
```
## Быстрая проверка после init/обновления
- `make bookings-init` (восстановление из дампа) должен завершиться без ошибок; в `bookings.bookings` ожидаем >0 строк (примерно 15k).
- `make bookings-generate` (генерация с нуля) тоже должен дать >0 строк, но занимает значительно больше времени.
- `make bookings-generate-day` добавляет следующий день после `max(book_date)`.
- Ручной вызов генерации из psql/DBeaver — только через DO-блок (подзапрос в аргументах `CALL` не работает):
```sql
DO $$
DECLARE
v_next_day timestamptz;
BEGIN
SELECT date_trunc('day', max(book_date)) + interval '1 day'
INTO v_next_day
FROM bookings.bookings;
CALL continue(v_next_day, 1);
END $$;
```
+54 -9
View File
@@ -1,34 +1,79 @@
-- Генерация ещё одного дня данных в демобазе bookings.
-- Если база пуста, берём стартовую дату из параметра :start_date (формат YYYY-MM-DD).
-- Если база пуста, берём стартовую дату из GUC bookings.start_date (по умолчанию: 2017-01-01).
DO $$
DECLARE
v_max_book_date timestamptz;
v_start_date timestamptz;
v_end_date timestamptz;
v_bookings_cnt bigint;
v_jobs integer := COALESCE(current_setting('bookings.jobs', true), '1')::integer;
v_init_days integer := COALESCE(current_setting('bookings.init_days', true), '1')::integer;
v_start_cfg text := COALESCE(current_setting('bookings.start_date', true), '2017-01-01');
BEGIN
-- Проверяем, что демобаза установлена
IF to_regclass('bookings.bookings') IS NULL THEN
RAISE EXCEPTION 'Таблица bookings.bookings не найдена. Сначала выполните make bookings-init.';
RAISE EXCEPTION 'Таблица bookings.bookings не найдена. Сначала выполните make bookings-init или make bookings-generate.';
END IF;
IF v_jobs < 1 THEN
RAISE EXCEPTION 'bookings.jobs должен быть >= 1. Текущее значение: %.', v_jobs;
END IF;
-- Ищем последнюю сгенерированную дату
SELECT max(book_date) INTO v_max_book_date FROM bookings.bookings;
IF v_max_book_date IS NULL THEN
-- База пустая: берём стартовую дату из psql-переменной
v_start_date := date_trunc('day', :'start_date'::timestamptz);
-- База пустая: берём стартовую дату из конфигурации (или дефолтную)
v_start_date := date_trunc('day', v_start_cfg::timestamptz);
ELSE
-- Продолжаем с дня, следующего за максимальной датой
v_start_date := date_trunc('day', v_max_book_date) + interval '1 day';
END IF;
v_end_date := v_start_date + interval '1 day';
-- Первая генерация вызывает generate(), последующие — continue()
IF v_max_book_date IS NULL THEN
CALL generate(v_start_date, v_end_date, 1);
v_end_date := v_start_date + (v_init_days || ' days')::interval;
CALL generate(v_start_date, v_end_date, v_jobs);
ELSE
CALL continue(v_end_date, 1);
v_end_date := v_start_date + interval '1 day';
-- Убираем VACUUM-ивенты из очереди: генератор demodb кладёт
-- VACUUM ANALYZE всей БД каждую неделю модельного времени.
-- На 500k+ строках это занимает минуты и бессмысленно для +1 дня.
DELETE FROM gen.events WHERE type = 'VACUUM';
CALL continue(v_end_date, v_jobs);
END IF;
-- continue() делает TRUNCATE gen.stat_jobs → AccessExclusiveLock.
-- Без COMMIT воркеры не могут INSERT INTO gen.stat_jobs → deadlock.
COMMIT;
-- Ждём завершения каждого воркера через dblink_is_busy().
-- Раньше опрашивали busy() (pg_stat_activity + application_name),
-- но это ненадёжно: воркер может обрабатывать VACUUM ANALYZE (десятки минут),
-- или зависнуть в пустой очереди — а busy() не отличает «полезную работу»
-- от «бесконечного pg_sleep(1) при пустом gen.events».
-- dblink_is_busy() проверяет состояние конкретного dblink-соединения напрямую.
IF v_jobs > 1 THEN
FOR i IN 1 .. v_jobs LOOP
WHILE dblink_is_busy('job' || i) = 1 LOOP
PERFORM pg_sleep(1);
END LOOP;
END LOOP;
END IF;
PERFORM dblink_disconnect(unnest(dblink_get_connections()));
-- Если данных нет, останавливаемся с понятной ошибкой
SELECT COUNT(*) INTO v_bookings_cnt FROM bookings.bookings;
IF v_bookings_cnt = 0 THEN
RAISE EXCEPTION 'Генератор demodb завершился, но bookings.bookings пустая. Проверьте применение патчей и логи генератора.';
END IF;
-- Генерация закончена — возвращаем synchronous_commit = on и сбрасываем
-- буферы на диск. Без этого docker compose down может убить PostgreSQL
-- до записи WAL → данные пропадут (особенно на WSL2).
ALTER DATABASE demo SET synchronous_commit = on;
SET synchronous_commit = on;
END $$;
CHECKPOINT;
+45
View File
@@ -0,0 +1,45 @@
--- a/engine.sql
+++ b/engine.sql
@@ -165,16 +165,22 @@
-- disconnect all previously opened connections
PERFORM dblink_disconnect(unnest(dblink_get_connections()));
- -- start parallel jobs
- FOR i IN 1 .. jobs LOOP
- connname := 'job' || i;
- PERFORM dblink_connect(connname, current_setting('gen.connstr'));
- res := CASE dblink_send_query(connname, format('CALL process_queue(%L)',end_date))
- WHEN 1 THEN 'ok' ELSE 'FAILED'
- END CASE;
- CALL log_message(0, format('Job %s (connname=%s): %s', i, connname, res));
- RAISE NOTICE 'Starting job %: %', i, res;
- END LOOP;
+ IF jobs = 1 THEN
+ -- jobs=1: синхронно, чтобы не убивать dblink-сессию при выходе из psql
+ CALL process_queue(end_date);
+ CALL log_message(0, 'Job 1 (local): ok');
+ ELSE
+ -- start parallel jobs
+ FOR i IN 1 .. jobs LOOP
+ connname := 'job' || i;
+ PERFORM dblink_connect(connname, current_setting('gen.connstr'));
+ res := CASE dblink_send_query(connname, format('CALL process_queue(%L)',end_date))
+ WHEN 1 THEN 'ok' ELSE 'FAILED'
+ END CASE;
+ CALL log_message(0, format('Job %s (connname=%s): %s', i, connname, res));
+ RAISE NOTICE 'Starting job %: %', i, res;
+ END LOOP;
+ END IF;
-- re-create booking.now()
EXECUTE format(
@@ -230,7 +236,8 @@
SELECT count(*) > 0
FROM pg_stat_activity
WHERE application_name = 'Airlines processor'
- AND state != 'idle';
+ AND state != 'idle'
+ AND pid <> pg_backend_pid();
END;
/*
@@ -0,0 +1,14 @@
--- a/install.sql
+++ b/install.sql
@@ -24,8 +24,9 @@
-- use UTC to avoid daylight-saving problems
ALTER DATABASE demo SET timezone = 'Etc/UTC';
--- connection string for dblink
-ALTER DATABASE demo SET gen.connstr = 'dbname=demo';
+-- Строку подключения для dblink (gen.connstr) задаёт Makefile
+-- с credentials текущего пользователя БД. Не хардкодим здесь,
+-- иначе VACUUM и другие dblink-вызовы падают без пароля.
-- airlines company name
ALTER DATABASE demo SET gen.airlines_name = 'PostgresPro';
@@ -0,0 +1,5 @@
--- a/install.sql
+++ b/install.sql
@@ -13,1 +13,1 @@
-DROP DATABASE demo;
+DROP DATABASE IF EXISTS demo WITH (FORCE);
Binary file not shown.
+84 -52
View File
@@ -1,3 +1,26 @@
x-airflow-common-env: &airflow-env
TZ: ${TZ:-Europe/Moscow}
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__API__AUTH_BACKENDS: "airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session"
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW__WEBSERVER__SECRET_KEY}
AIRFLOW_CONN_GREENPLUM_CONN: postgresql://${GP_USER}:${GP_PASSWORD}@greenplum:${GP_PORT:-5432}/${GP_DB}
AIRFLOW_CONN_BOOKINGS_DB: postgresql://${BOOKINGS_DB_USER}:${BOOKINGS_DB_PASSWORD}@bookings-db:5432/demo
x-airflow-common-volumes: &airflow-volumes
- ./airflow/dags:/opt/airflow/dags
- ./sql:/sql:ro
- airflow_logs:/opt/airflow/logs
x-airflow-common-depends: &airflow-depends
pgmeta:
condition: service_healthy
greenplum:
condition: service_healthy
airflow-init:
condition: service_completed_successfully
services:
# Postgres только для Airflow метаданных
pgmeta:
@@ -5,15 +28,16 @@ services:
# container_name: gp_pgmeta
env_file: .env
environment:
TZ: ${TZ:-Europe/Moscow}
POSTGRES_USER: ${PG_USER}
POSTGRES_PASSWORD: ${PG_PASSWORD}
POSTGRES_DB: ${PG_DB}
ports:
- "5433:5432"
- "${PGMETA_PORT:-5433}:5432"
volumes:
- pgmeta:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PG_USER} -d ${PG_DB}"]
test: [ "CMD-SHELL", "pg_isready -U ${PG_USER} -d ${PG_DB}" ]
interval: 5s
timeout: 5s
retries: 20
@@ -23,6 +47,7 @@ services:
image: postgres:16
env_file: .env
environment:
TZ: ${TZ:-Europe/Moscow}
POSTGRES_USER: ${BOOKINGS_DB_USER}
POSTGRES_PASSWORD: ${BOOKINGS_DB_PASSWORD}
POSTGRES_DB: ${BOOKINGS_DB_NAME}
@@ -33,103 +58,110 @@ services:
# В этот каталог будет монтироваться генератор demodb
- ./bookings:/bookings:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${BOOKINGS_DB_USER} -d ${BOOKINGS_DB_NAME}"]
test: [ "CMD-SHELL", "pg_isready -U ${BOOKINGS_DB_USER} -d ${BOOKINGS_DB_NAME}" ]
interval: 5s
timeout: 5s
retries: 20
greenplum:
image: woblerr/greenplum:6.27.1 # based on https://github.com/woblerr/docker-greenplum
build:
context: .
dockerfile: Dockerfile.greenplum
image: greenplum-custom:6.27.1
# container_name: gp_single
# hostname: gpdbsne
hostname: gpdbsne
environment:
TZ: ${TZ:-Europe/Moscow}
GREENPLUM_USER: ${GP_USER:-gpadmin}
GREENPLUM_PASSWORD: ${GP_PASSWORD:-gpadmin}
GREENPLUM_DATABASE_NAME: ${GP_DB:-gpadmin}
# GP_PORT: ${GP_PORT:-5432}
# Порты: внешний 5432
GREENPLUM_DATABASE_NAME: ${GP_DB:-gp_dwh}
GREENPLUM_PXF_ENABLE: "true"
PXF_SEED_OVERWRITE: ${PXF_SEED_OVERWRITE:-0}
PXF_SYNC_ON_START: ${PXF_SYNC_ON_START:-0}
# Порты: внешний 5435 (на хосте)
ports:
- "${GP_PORT}:5432"
- "${GP_HOST_PORT:-5435}:5432"
volumes:
- ./sql:/sql:ro
- greenplum_data:/data
- airflow_data:/opt/airflow/data
# Простая проверка доступности: psql откликается
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U ${GP_USER:-gpadmin} -d ${GP_DB:-gpadmin} || echo 1"]
# Ждём не только доступность GPDB, но и готовность PXF,
# чтобы Airflow не стартовал раньше PXF.
test: [ "CMD-SHELL", "/usr/local/greenplum-db/bin/pg_isready -h 127.0.0.1 -p 5432 -U ${GP_USER:-gpadmin} -d ${GP_DB:-gp_dwh} && PGPASSWORD=${GP_PASSWORD:-gpadmin} /usr/local/greenplum-db/bin/psql -h 127.0.0.1 -p 5432 -U ${GP_USER:-gpadmin} -d ${GP_DB:-gp_dwh} -t -A -c \"SELECT 1 FROM pg_extension WHERE extname='pxf';\" | grep -q 1 && su - ${GP_USER:-gpadmin} -c '/usr/local/pxf/bin/pxf cluster status' >/dev/null 2>&1" ]
interval: 10s
timeout: 5s
retries: 30
start_period: 40s
airflow-webserver:
image: apache/airflow:2.9.2
container_name: gp_airflow_web
build:
context: .
dockerfile: Dockerfile.airflow
image: airflow-custom:latest
container_name: gp_airflow_webserver
env_file: .env
environment:
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW__WEBSERVER__SECRET_KEY}
command: >
bash -lc "pip install --no-cache-dir -r /opt/airflow/requirements.txt &&
airflow webserver"
<<: *airflow-env
command: bash -lc "rm -f /opt/airflow/airflow-webserver.pid && exec airflow webserver"
ports:
- "8080:8080"
- "${AIRFLOW_WEB_PORT:-8080}:8080"
volumes:
- ./airflow/dags:/opt/airflow/dags
- ./sql:/sql:ro
- airflow_logs:/opt/airflow/logs
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
- airflow_data:/opt/airflow/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 40s
depends_on:
pgmeta:
condition: service_healthy
greenplum:
condition: service_healthy
<<: *airflow-depends
airflow-scheduler:
image: apache/airflow:2.9.2
container_name: gp_airflow_sch
build:
context: .
dockerfile: Dockerfile.airflow
image: airflow-custom:latest
container_name: gp_airflow_scheduler
env_file: .env
environment:
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW__WEBSERVER__SECRET_KEY}
command: >
bash -lc "pip install --no-cache-dir -r /opt/airflow/requirements.txt &&
airflow scheduler"
<<: *airflow-env
command: airflow scheduler
volumes:
- ./airflow/dags:/opt/airflow/dags
- ./sql:/sql:ro
- airflow_logs:/opt/airflow/logs
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
- airflow_data:/opt/airflow/data
depends_on:
pgmeta:
condition: service_healthy
greenplum:
condition: service_healthy
<<: *airflow-depends
airflow-init:
image: apache/airflow:2.9.2
# container_name: gp_airflow_init
build:
context: .
dockerfile: Dockerfile.airflow
image: airflow-custom:latest
# Имя контейнера закомментировано (одноразовый сервис)
user: "0"
env_file: .env
environment:
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW__WEBSERVER__SECRET_KEY}
user: "0:0"
<<: *airflow-env
volumes:
- ./airflow/dags:/opt/airflow/dags
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
- airflow_data:/opt/airflow/data
- ./sql:/sql:ro
- airflow_logs:/opt/airflow/logs
command: >
bash -lc "
set -e;
# mkdir -p /opt/airflow/data && chmod -R 777 /opt/airflow/data;
# su - airflow;
pip install --no-cache-dir -r /opt/airflow/requirements.txt;
mkdir -p /opt/airflow/logs && chown -R airflow:root /opt/airflow/logs;
# Дожидаемся готовности БД ретрая миграции
for i in {1..30}; do
airflow db migrate && break || echo 'waiting for pgmeta' && sleep 3;
su -s /bin/bash airflow -c \"PATH='/home/airflow/.local/bin:$${PATH}' airflow db migrate\" && break || echo 'waiting for pgmeta' && sleep 3;
done;
# Создаём админа; при повторном запуске не падаем
airflow users create --username ${AIRFLOW_USER} --password ${AIRFLOW_PASSWORD} --firstname Admin --lastname User --role Admin --email admin@example.org || true
su -s /bin/bash airflow -c \"PATH='/home/airflow/.local/bin:$${PATH}' airflow users create --username ${AIRFLOW_USER} --password ${AIRFLOW_PASSWORD} --firstname Admin --lastname User --role Admin --email admin@example.org\" || true
"
depends_on:
pgmeta:
@@ -139,4 +171,4 @@ volumes:
pgmeta:
bookings_data:
greenplum_data:
airflow_data:
airflow_logs:
+46
View File
@@ -0,0 +1,46 @@
# Документация
Этот каталог содержит дополнительные материалы к учебному стенду.
## Быстрый путь (для менти)
- [Быстрый старт и команды](../README.md)
- [Учебные задания](assignment/README.md)
- [План тестирования и проверки](../TESTING.md)
- [Главный учебный DAG: bookings → stg](bookings_to_gp_stage.md)
- [Учебный DAG: stg -> ods](bookings_to_gp_ods.md)
- [Учебный DAG: ods -> dds](bookings_to_gp_dds.md)
- [Учебный DAG: dds -> dm](bookings_to_gp_dm.md)
## Дизайн (`design/`)
- [Единые конвенции нейминга DWH (служебные поля и SCD)](design/naming_conventions.md)
- [Схема БД всех слоёв DWH](design/db_schema.md)
- [Дизайн-документ STG](design/bookings_stg_design.md)
- [Дизайн-документ ODS](design/bookings_ods_design.md)
- [Дизайн-документ DDS](design/bookings_dds_design.md)
- [Дизайн-документ DM](design/bookings_dm_design.md)
- [Архитектурные решения (ADR)](design/architecture_review.md)
- [PRD: стратегия курсовой](design/PRD.md)
- [Дизайн задания](design/assignment_design.md)
- [Развитие лабораторных: аудитория, решения и критерии заданий](design/labs_development.md)
## Справочники (`reference/`)
- [Как устроен Docker-стенд (образы, Connections, переменные окружения)](stack.md)
- [PXF в этом проекте (проектная реализация)](reference/pxf_bookings.md)
- [Про время/UTC в bookings](reference/bookings_tz.md)
- [Известные проблемы bookings-db](reference/bookings_db_issues.md)
- [Бенчмарк генерации данных](reference/bookings_generation_benchmark.md)
- [QA-план отладки пайплайна](reference/qa-plan.md)
- [Порядок запуска DAG-ов](dag_execution_order.md)
- [End-to-end протокол тестирования](e2e-etl-test-protocol.md)
- [Тестирование DAG-ов через API](agent-dag-testing.md)
## Планы (`plans/`)
Активные планы работ. После выполнения переносятся в `archive/`.
## Архив (`archive/`)
Выполненные планы, закрытые ревью. Ссылки внутри файлов могут быть устаревшими.
+89
View File
@@ -0,0 +1,89 @@
# Тестирование DAG (гайд для AI-агентов)
Этот документ описывает, как агенту взаимодействовать с Airflow (без UI) для точечной проверки и отладки DAG'ов в процессе разработки.
## 0. Состояние среды (Предварительная проверка)
Прежде чем тестировать DAG, убедитесь, что стек работает:
```bash
docker compose ps
```
Если контейнеров нет, поднимите стек: `make up`.
Если исходная база данных пуста (например, после `make clean`), проинициализируйте её: `make bookings-init`.
---
## 1. Быстрая проверка структуры графа (Локально)
При любом изменении Python-кода DAG'а сначала проверьте, что он компилируется и структура графа корректна:
```bash
make test
```
Это запустит smoke-тесты (`tests/test_dags_smoke.py`), которые проверят целостность всех DAG'ов без обращения к базе данных.
---
## 2. Запуск конкретного DAG'а (REST API)
Для тестирования загрузки данных запустите измененный DAG через REST API (базовый URL `http://localhost:8080/api/v1`, креды взять из `.venv`).
**Шаг 2.1. Снять DAG с паузы (при старте стенда все DAG'и на паузе — этот шаг обязателен!):**
```bash
curl -s -X PATCH "http://localhost:8080/api/v1/dags/<dag_id>" \
-u admin:admin -H "Content-Type: application/json" -d '{"is_paused": false}'
```
**Шаг 2.2. Запустить DAG:**
```bash
curl -s -X POST "http://localhost:8080/api/v1/dags/<dag_id>/dagRuns" \
-u admin:admin -H "Content-Type: application/json" -d '{}' | jq '{dag_run_id}'
```
**Шаг 2.3. Проверить статус выполнения:**
Используйте `dag_run_id` из предыдущего шага:
```bash
curl -s "http://localhost:8080/api/v1/dags/<dag_id>/dagRuns/<dag_run_id>" \
-u admin:admin | jq '{state}'
```
Повторяйте запрос, пока `state` не станет `success` или `failed`.
---
## 3. Отладка упавших задач
Если DAG перешел в статус `failed`, найдите упавшую задачу:
**Шаг 3.1. Получить статусы всех задач:**
```bash
curl -s "http://localhost:8080/api/v1/dags/<dag_id>/dagRuns/<dag_run_id>/taskInstances" \
-u admin:admin | jq '.task_instances[] | {task_id, state}'
```
**Шаг 3.2. Посмотреть логи упавшей задачи (через CLI Airflow):**
REST API отдает логи сложно, поэтому для логов проще использовать `docker compose exec`:
```bash
docker compose exec airflow-webserver airflow tasks logs <dag_id> <task_id> <run_id>
```
*(Совет: ищите в логах слова `ERROR`, `Exception` или вывод SQL-ошибок от PostgresOperator).*
---
## 4. Проверка результата в DWH (Greenplum)
Успешное выполнение DAG'а (зеленый статус) не гарантирует, что данные загрузились правильно (например, если источник был пуст). Проверьте целевые таблицы напрямую:
```bash
docker compose exec greenplum bash -lc "su - gpadmin -c \"/usr/local/greenplum-db/bin/psql -t -A -d gp_dwh -c 'SELECT COUNT(*) FROM <схема>.<таблица>;'\""
```
Убедитесь, что таблица содержит ожидаемое количество строк.
---
## 5. Полный сквозной тест (E2E)
Если вы вносили масштабные изменения, затрагивающие несколько слоев DWH, или меняли DDL таблиц, рекомендуется прогнать полный конвейер (STG -> ODS -> DDS -> DM):
```bash
make e2e-etl
```
Этот скрипт сам запустит все нужные DAG'и в правильном порядке и проверит результаты.
@@ -0,0 +1,149 @@
# Ревью решения (образец для студентов): `bookings-db` → `stg` в Greenplum
Этот документ фиксирует рекомендации по улучшению учебного решения ETL (Airflow + Greenplum + PXF)
на основе ревью изменений ветки `chore/bookings-etl` (добавление полного STG слоя и пайплайна загрузки).
Цель ревью — сделать решение **безоговорочно рекомендуемым** к изучению начинающими:
понятным, предсказуемым, с корректной терминологией и честными инженерными компромиссами.
---
## 1) Сильные стороны решения (что уже хорошо и стоит сохранить)
1. **Единый “шаблон” по таблицам** в `sql/stg/`:
- `{table}_ddl.sql` — создаёт `*_ext` и внутреннюю таблицу;
- `{table}_load.sql` — загружает данные;
- `{table}_dq.sql` — валидирует качество и останавливает пайплайн при проблемах.
Это отличная учебная структура: студент быстро понимает, “где что лежит” и как добавлять новые таблицы.
2. **DAG как оркестратор, SQL как логика**:
- `airflow/dags/bookings_to_gp_stage.py` и `airflow/dags/bookings_stg_ddl.py` используют `PostgresOperator`
и читают SQL с диска через `template_searchpath="/sql"`.
Это соответствует “канонической” модели: Airflow управляет шагами, а трансформации живут в SQL.
3. **Понятные сообщения при падении DQ** (в большинстве скриптов): студенту легче дебажить.
---
## 2) Критичные замечания (статус на текущий момент)
### 2.1. Некорректные утверждения про MPP и co-location (статус: исправлено)
В Greenplum производительность JOIN сильно зависит от распределения данных по сегментам.
Если ключ распределения двух таблиц совпадает с ключом JOIN — часто удаётся обойтись без перераспределения данных (motion).
Раньше в некоторых DDL-комментариях обещалась co-location там, где её не будет.
Это педагогически опасно: студент запоминает неверную модель, а потом “не понимает”, почему запросы медленные.
Что сделано:
- DDL-комментарии приведены к честной формулировке “ключ выбран так-то, но JOIN по другим ключам может требовать motion”.
- Исправлены места, где co-location заявлялась ошибочно (в т.ч. `routes`, `flights`, `segments`, `boarding_passes`).
Файлы: `sql/stg/routes_ddl.sql`, `sql/stg/flights_ddl.sql`, `sql/stg/segments_ddl.sql`, `sql/stg/boarding_passes_ddl.sql`.
### 2.2. DQ-проверки ссылочной целостности: “текущий батч” vs “вся история” (статус: исправлено)
Часть DQ-скриптов проверяет наличие “родительских” записей в таблице **без фильтра `_load_id`**.
При append-only истории это может скрыть проблемы текущей загрузки:
родитель был загружен в прошлом батче → проверка пройдёт, даже если текущий батч родителя не загрузил.
Что сделано:
- `routes_dq.sql`: проверка airports/airplanes стала батч-строгой (`_load_id = текущий батч`).
- `seats_dq.sql`: проверка airplanes стала батч-строгой (`_load_id = текущий батч`).
- `flights_dq.sql`: проверка routes стала батч-строгой (`_load_id = текущий батч`).
Примечание (почему не везде `_load_id = текущий батч`):
- Если дочерняя таблица грузится инкрементом, то ссылки могут указывать на “исторические” записи,
загруженные в предыдущих батчах → для таких связей корректнее проверять “существует в STG вообще”.
- Для `boarding_passes` (full snapshot) ссылки на `tickets/segments` также проверяются по STG-истории,
потому что `tickets/segments` не перезагружаются полным снэпшотом каждый запуск.
### 2.3. Smoke-тесты DAG’ов (статус: исправлено)
Что сделано:
- Тесты усилены: теперь проверяются ключевые зависимости графа через `get_direct_relatives(upstream=False)` и “барьеры” через `get_flat_relatives(upstream=False)`.
### 2.4. Документация по DAG (статус: синхронизировано)
Что сделано:
- `docs/bookings_to_gp_stage.md` обновлён так, чтобы отражать текущий набор таблиц и шагов пайплайна.
---
## 3) Рекомендации по качеству и читаемости (Clean Code для SQL и DAG)
### 3.1. “Empty window” в инкременте: договориться о политике (fail vs skip) (статус: исправлено)
Раньше поведение было разным:
- часть DQ-скриптов падала, если в окне инкремента 0 строк;
- `boarding_passes_dq.sql` делал `RAISE NOTICE` и `RETURN`.
Обе стратегии допустимы, но в учебном решении важно выбрать одну и объяснить:
- **Fail** полезен, когда “ожидаем данные в каждом запуске” (например, учебный генератор должен добавлять день);
- **Skip** полезен, когда “окно может быть пустым и это нормально”.
Выбранная политика для учебного стенда:
- Для инкрементальных таблиц (`bookings`, `tickets`, `flights`, `segments`) “пустое окно” **допустимо**:
DQ логирует `NOTICE` и завершает проверку, не падая.
- Для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) пустой источник считаем ошибкой:
это почти всегда признак проблем с PXF/источником.
### 3.2. Комментарии в `*_load.sql`: точнее формулировать “идемпотентность”, а не “дедупликацию источника” (статус: исправлено)
Типовой паттерн:
```sql
WHERE NOT EXISTS (
SELECT 1 FROM stg.table WHERE _load_id = '{{ run_id }}' AND key = ext.key
);
```
Это в первую очередь защита от повторного запуска того же таска в рамках одного `_load_id` (retry),
а не “лечение” дублей в источнике.
Что сделано:
- В `sql/stg/*_load.sql` комментарии приведены к формулировке “идемпотентность при повторном запуске/ретрае”.
### 3.3. Проверка составных ключей: избегать склейки строк (статус: исправлено)
Паттерн вида `COUNT(DISTINCT col1 || '|' || col2)` теоретически может давать коллизии (если в данных встречается разделитель).
В учебном стенде риск небольшой, но как “эталон” лучше показывать более безопасный подход.
Что сделано:
- Заменили склейку строк на `COUNT(DISTINCT md5(ROW(col1, col2)::text))` в DQ‑скриптах для составных ключей.
Такой подход сохраняет DV‑стиль и убирает неоднозначность разделителей.
---
## 4) Практические примеры “как улучшить”
### 4.1. Батч-строгая ссылочная целостность (пример подхода)
Если таблицы грузятся как snapshot в рамках батча, проверки можно сделать батч-строгими:
“в текущем батче ссылки указывают на строки текущего батча”.
Идея (пример для routes → airports):
```sql
LEFT JOIN stg.airports AS a
ON r.departure_airport = a.airport_code
AND a._load_id = v_batch_id
```
### 4.2. Smoke-тест реального графа (минимальный полезный уровень)
Вместо “таски существуют” лучше проверять ключевые зависимости:
```python
tickets_dq = dag.get_task("check_tickets_dq")
airports_load = dag.get_task("load_airports_to_stg")
assert airports_load in tickets_dq.get_direct_relatives(upstream=False)
```
---
## 5) Чек-лист “готово как эталон”
- [x] В DDL-комментариях нет неверных обещаний про co-location/уникальность ключей.
- [x] Для DQ определена и описана политика “0 строк”: где fail, где skip.
- [x] DQ ссылочной целостности не маскирует проблемы текущего батча (batch-строгие проверки там, где это уместно).
- [x] `docs/bookings_to_gp_stage.md` соответствует фактическому DAG.
- [x] Smoke-тесты проверяют хотя бы критические зависимости графа.
@@ -0,0 +1,205 @@
# План: Денормализация dim_routes + упрощение route_performance
> **Статус:** реализовано.
## Контекст
`dds.dim_routes` хранит только FK-ссылки (`departure_airport`, `arrival_airport`, `airplane_code`) без атрибутов (город, модель самолёта, кол-во мест). Из-за этого витрина `dm.route_performance` вынуждена делать 4-JOIN цепочку вместо одного JOIN к измерению. Это противоречит принципу Кимбалла (измерение должно быть «самодостаточным») и усложняет учебный материал.
**Цель:** добавить в dim_routes 4 денормализованных колонки, упростить route_performance до 1 JOIN, показать студентам правильную Star Schema.
---
## Файлы для изменения
| # | Файл | Суть |
|---|---|---|
| 1 | `sql/dds/dim_routes_ddl.sql` | +4 колонки через ALTER TABLE |
| 2 | `sql/dds/dim_routes_load.sql` | JOIN к airports/airplanes при INSERT + refresh-шаг |
| 3 | `sql/dds/dim_routes_dq.sql` | NOT NULL проверка для новых колонок (current-срез) |
| 4 | `airflow/dags/bookings_to_gp_dds.py` | Зависимость: airports+airplanes DQ → routes load |
| 5 | `sql/dm/route_performance_load.sql` | Упрощение 4-JOIN → 1-JOIN |
| 6 | `tests/test_dags_smoke.py` | Новые assert для зависимости routes от airports/airplanes |
| 7 | `docs/internal/db_schema.md` | Обновить схему dim_routes |
---
## Шаг 1. DDL — `sql/dds/dim_routes_ddl.sql`
После существующего `COMMENT ON TABLE` добавить учебный комментарий и 4 ALTER TABLE:
```sql
-- Денормализация атрибутов из SCD1-измерений (dim_airports, dim_airplanes).
--
-- Учебный комментарий (Kimball Star Schema):
-- Измерение должно быть «самодостаточным»: один JOIN к dim_routes —
-- и аналитик видит маршрут, города, модель самолёта и кол-во мест.
--
-- Эти колонки НЕ участвуют в hashdiff. Версия SCD2 фиксирует изменения
-- атрибутов маршрута (аэропорт, самолёт, расписание). Если изменится
-- название города — обновим отдельным refresh-шагом, не создавая новую версию.
ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS departure_city TEXT;
ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS arrival_city TEXT;
ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS airplane_model TEXT;
ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS total_seats INTEGER;
```
Колонки nullable — `ALTER TABLE ADD COLUMN ... NOT NULL` без DEFAULT упадёт на существующих строках. DQ проверит NOT NULL для current-среза.
---
## Шаг 2. Load — `sql/dds/dim_routes_load.sql`
Структура: 3 фазы вместо 2.
**Фаза 1 (без изменений):** temp table + hashdiff из ods.routes, UPDATE закрытие версий. Hashdiff **не включает** денормализованные атрибуты.
**Фаза 2 (изменён INSERT):** при вставке новой версии — LEFT JOIN к dim_airports (×2) и dim_airplanes для заполнения departure_city, arrival_city, airplane_model, total_seats.
Конкретно: в INSERT (Statement 2, строки 59-107) добавить:
- В список колонок: `departure_city, arrival_city, airplane_model, total_seats`
- В SELECT: `dep.city, arr.city, air.model, air.total_seats`
- LEFT JOIN к dim_airports и dim_airplanes (LEFT — чтобы не терять маршруты при отсутствии аэропорта; DQ поймает)
**Фаза 3 (новая):** refresh денормализованных атрибутов для всех current-версий:
```sql
-- Фаза 3: Обновление денормализованных атрибутов (refresh).
-- Нужна для SCD1-изменений в dim_airports/dim_airplanes (напр. переименование города).
-- Обновляем ТОЛЬКО current-версии (valid_to IS NULL).
UPDATE dds.dim_routes AS d
SET departure_city = dep.city,
arrival_city = arr.city,
airplane_model = air.model,
total_seats = air.total_seats,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM dds.dim_airports AS dep,
dds.dim_airports AS arr,
dds.dim_airplanes AS air
WHERE d.valid_to IS NULL
AND dep.airport_bk = d.departure_airport
AND arr.airport_bk = d.arrival_airport
AND air.airplane_bk = d.airplane_code
AND (
d.departure_city IS DISTINCT FROM dep.city
OR d.arrival_city IS DISTINCT FROM arr.city
OR d.airplane_model IS DISTINCT FROM air.model
OR d.total_seats IS DISTINCT FROM air.total_seats
);
```
Этот же шаг при первом запуске заполнит колонки для существующих данных (backfill).
---
## Шаг 3. DQ — `sql/dds/dim_routes_dq.sql`
Перед финальным `RAISE NOTICE` добавить проверку:
```sql
-- Денормализованные поля не пустые в текущих (актуальных) версиях.
-- Исторические версии могли быть загружены ДО добавления колонок — пропускаем.
SELECT COUNT(*) INTO v_null_count
FROM dds.dim_routes
WHERE valid_to IS NULL
AND (departure_city IS NULL OR arrival_city IS NULL
OR airplane_model IS NULL OR total_seats IS NULL);
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в current-срезе dim_routes найдены NULL денормализованные поля: %',
v_null_count;
END IF;
```
---
## Шаг 4. DAG — `airflow/dags/bookings_to_gp_dds.py`
Убрать `load_dds_dim_routes` из параллельного fan-out, добавить зависимость от DQ airports и airplanes:
```python
# Было:
dq_dds_dim_calendar >> [
load_dds_dim_airports,
load_dds_dim_airplanes,
load_dds_dim_tariffs,
load_dds_dim_passengers,
load_dds_dim_routes, # параллельно
]
# Стало:
dq_dds_dim_calendar >> [
load_dds_dim_airports,
load_dds_dim_airplanes,
load_dds_dim_tariffs,
load_dds_dim_passengers,
]
# dim_routes зависит от airports и airplanes (денормализация).
[dq_dds_dim_airports, dq_dds_dim_airplanes] >> load_dds_dim_routes
```
Транзитивная зависимость от calendar сохраняется через airports/airplanes.
---
## Шаг 5. Витрина — `sql/dm/route_performance_load.sql`
Упростить Шаг 3. Было 4 JOIN → станет 1 JOIN:
```sql
FROM tmp_route_metrics m
JOIN dds.dim_routes r_curr
ON m.route_bk = r_curr.route_bk AND r_curr.valid_to IS NULL
```
SELECT использует `r_curr.departure_airport AS departure_airport_bk`, `r_curr.departure_city`, `r_curr.airplane_code AS airplane_bk`, `r_curr.airplane_model`, `r_curr.total_seats`. Добавить учебный комментарий: «Благодаря денормализации dim_routes — один JOIN вместо четырёх.»
---
## Шаг 6. Тесты — `tests/test_dags_smoke.py`
Добавить в `test_bookings_to_gp_dds_dag_structure`:
```python
# dim_routes зависит от airports и airplanes (денормализация).
_assert_reachable(dag, "dq_dds_dim_airports", "load_dds_dim_routes")
_assert_reachable(dag, "dq_dds_dim_airplanes", "load_dds_dim_routes")
```
---
## Шаг 7. Документация — `docs/internal/db_schema.md`
Обновить описание dim_routes: добавить 4 новые колонки, пометить «денормализовано из dim_airports/dim_airplanes». Обновить граф зависимостей DAG.
---
## Что НЕ меняется
- **fact_flight_sales_load.sql** — факт резолвит SK через свои JOIN-ы. Без изменений.
- **passenger_loyalty, monthly_overview** — используют только `route_bk` из dim_routes.
- **airport_traffic** — вообще не джойнит dim_routes.
- **hashdiff** — остаётся прежним (только атрибуты из ods.routes).
---
## Верификация
1. `make ddl-gp` — накатить DDL (ALTER TABLE добавит колонки)
2. Запустить DAG `bookings_to_gp_dds` — проверить:
- airports/airplanes грузятся ДО routes (Graph view)
- Фаза 3 (refresh) заполняет departure_city, arrival_city, airplane_model, total_seats
- DQ проходит без ошибок
3. Запустить DAG `bookings_to_gp_dm` — проверить:
- route_performance загружается с 1 JOIN
- Витрина содержит корректные города и модели самолётов
4. `make test` — smoke-тесты DAG проходят (включая новые assert'ы)
5. SQL-проверка:
```sql
SELECT route_bk, departure_city, arrival_city, airplane_model, total_seats
FROM dds.dim_routes WHERE valid_to IS NULL LIMIT 5;
```
@@ -0,0 +1,220 @@
# План ревизии документации
> Статус: **ЧЕРНОВИК v3** | Дата: 2026-03-10
> Контекст: перед Этапом 2 (подготовка main) нужно навести порядок в docs/
---
## Проблемы
- `docs/internal/` — свалка: дизайн-документы, планы, ревью, баг-трекеры, стандарты
- 3 архивных плана лежат рядом с живыми документами (неотличимы)
- 3 осиротевших документа (никто не ссылается)
- `educational-tasks.md` в корне — устарел (раздел 2.2 говорит «ODS/DDS/DM будут позже»)
- 5 документов содержат устаревшие фрагменты
- `docs/README.md` не знает про несколько живых документов
---
## Принципы
- **Архив замораживается.** Файлы в `docs/archive/` не правим — ссылки внутри них
могут быть битыми, это ожидаемо. Они сохраняются как исторические артефакты.
- **Активные планы** живут в `docs/plans/`, после выполнения переезжают в `docs/archive/`.
- **Студенческий entry point** не должен исчезать: пока `analyst_spec.md` (Этап 3)
не создан, в `docs/assignment/` будет заглушка `README.md` со ссылкой на эталонный
срез для самостоятельного изучения.
---
## Фаза 1. Структура каталогов
Создать новые каталоги:
```
docs/design/ — дизайн-документы, стандарты, архитектура
docs/reference/ — техническая справка, баг-трекеры, бенчмарки
docs/plans/ — активные планы работ
docs/archive/ — выполненные планы, закрытые ревью
docs/assignment/ — заглушка README.md (подготовка для Этапа 3)
```
---
## Фаза 2. Перемещение файлов
### В `docs/archive/` (4 файла)
| Откуда | Файл | Причина |
|--------|-------|---------|
| корень | `educational-tasks.md` | Устарел, заменён `assignment_design.md` |
| `docs/internal/` | `stg_naming_unification_plan.md` | Выполнен |
| `docs/internal/` | `dim_routes_denormalization_plan.md` | Выполнен |
| `docs/internal/` | `bookings_stg_code_review.md` | Все замечания закрыты |
### В `docs/plans/` (1 файл)
| Откуда | Файл | Примечание |
|--------|-------|---------|
| `docs/internal/` | `docs_restructuring_plan.md` | Этот план — активный; после выполнения → `docs/archive/` |
### В `docs/design/` (9 файлов из `docs/internal/`)
| Файл | Роль |
|------|------|
| `PRD.md` | Стратегия продукта |
| `assignment_design.md` | Дизайн курсового задания |
| `naming_conventions.md` | Стандарт нейминга (единый источник) |
| `db_schema.md` | Схема БД всех слоёв DWH |
| `bookings_stg_design.md` | Дизайн STG-слоя |
| `bookings_ods_design.md` | Дизайн ODS-слоя |
| `bookings_dds_design.md` | Дизайн DDS-слоя |
| `bookings_dm_design.md` | Дизайн DM-слоя |
| `architecture_review.md` | Архитектурные решения (ADR) |
### В `docs/reference/` (5 файлов из `docs/internal/`)
| Файл | Роль |
|------|------|
| `pxf_bookings.md` | PXF: настройка, проблемы |
| `bookings_tz.md` | Источник bookings-db |
| `bookings_db_issues.md` | Известные проблемы bookings-db |
| `bookings_generation_benchmark.md` | Бенчмарк генерации |
| `qa-plan.md` | План отладки пайплайна |
**Итог:** `docs/internal/` опустеет → удалить.
---
## Фаза 3. Обновление перекрёстных ссылок
### Категория A. Корневые и публичные файлы (ссылаются на `docs/internal/`)
| Файл | Ссылок | Детали замен |
|------|--------|------|
| `AGENTS.md` | 2 | строки 15, 45: `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
| `TODO.md` | 6 | строки 6, 7, 43: → `docs/design/`; строка 121: → `docs/reference/pxf_bookings.md`; строка 128: → `docs/reference/bookings_db_issues.md`; строка 141: → `docs/archive/dim_routes_denormalization_plan.md` |
| `docs/README.md` | 7 | строки 18-24: все `internal/*` → `design/*` или `reference/*` |
| `docs/bookings_to_gp_stage.md` | 2 | строка 151: → `reference/pxf_bookings.md`; строка 156: → `archive/bookings_stg_code_review.md` |
| `docs/stack.md` | 1 | строка 85: → `reference/pxf_bookings.md` |
### Категория B. Файлы внутри `docs/design/` и `docs/reference/`
Полный реестр ссылок с `docs/internal/` в файлах, которые переедут в `design/` или
`reference/`. Все требуют обновления — либо кросс-каталожные пути, либо display-тексты.
**B1. Кросс-каталожные ссылки (ссылка ведёт в другой каталог — путь сломается):**
| Файл (→ design/) | Строка | Ссылка | Новый путь |
|------|--------|--------|------|
| `db_schema.md` | 426 | `bookings_tz.md` (отн.) | `../reference/bookings_tz.md` |
| `db_schema.md` | 427 | `pxf_bookings.md` (отн.) | `../reference/pxf_bookings.md` |
| `db_schema.md` | 425 | `bookings_stg_code_review.md` (отн.) | `../archive/bookings_stg_code_review.md` |
| `bookings_stg_design.md` | 5 | `docs/internal/bookings_tz.md` | `../reference/bookings_tz.md` |
| `bookings_stg_design.md` | 130 | `docs/internal/bookings_tz.md` | `../reference/bookings_tz.md` |
| `bookings_stg_design.md` | 131 | `docs/internal/pxf_bookings.md` | `../reference/pxf_bookings.md` |
| Файл (→ reference/) | Строка | Ссылка | Новый путь |
|------|--------|--------|------|
| `qa-plan.md` | 10 | `docs/internal/bookings_db_issues.md` | `bookings_db_issues.md` (тот же каталог) |
**B2. Внутрикаталожные, но с полным путём `docs/internal/...` (путь не сломается
для ссылок с относительным target, но display-текст устареет):**
| Файл (→ design/) | Строки | Что обновить |
|------|--------|------|
| `PRD.md` | 209 | `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
| `bookings_ods_design.md` | 48 | display-текст `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
| `bookings_dds_design.md` | 8 | `docs/internal/bookings_ods_design.md` → `docs/design/bookings_ods_design.md` |
| `bookings_dds_design.md` | 185 | display-текст `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
| `bookings_dds_design.md` | 828-829 | `docs/internal/bookings_dds_design.md`, `docs/internal/db_schema.md` → `docs/design/...` |
| `bookings_dds_design.md` | 945 | `docs/internal/db_schema.md` → `docs/design/db_schema.md` |
| `bookings_dm_design.md` | 279 | `docs/internal/db_schema.md` → `docs/design/db_schema.md` |
| `bookings_dm_design.md` | 283, 291 | `docs/internal/bookings_dm_design.md` → `docs/design/bookings_dm_design.md` |
| `bookings_dm_design.md` | 395 | `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
| `db_schema.md` | 26 | display-текст `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
| `db_schema.md` | 421-423 | display-тексты `docs/internal/bookings_*_design.md` → `docs/design/...` |
| `architecture_review.md` | 70 | `docs/internal/bookings_dm_design.md` → `docs/design/bookings_dm_design.md` |
| `architecture_review.md` | 146 | `docs/internal/distribution_strategy.md` → **удалить путь** (файл не существует, оставить как текстовый backlog-пункт без ссылки) |
### Категория C. Ссылки на `educational-tasks.md`
| Файл | Действие |
|------|----------|
| `README.md` (корень) | Заменить ссылку на `educational-tasks.md` → `docs/assignment/` |
| `docs/README.md` | Заменить ссылку на `educational-tasks.md` → `assignment/` |
### Категория D. Файлы в `docs/archive/` — НЕ ТРОГАЕМ
Архивные файлы замораживаются. Ссылки внутри них могут быть битыми — это ожидаемо.
### Комментарий в SQL
`sql/dm/sales_report_ddl.sql` строка 52: `naming_conventions.md` (без пути) —
оставить как есть (комментарий, не ссылка; путь и так неточный).
---
## Фаза 4. Актуализация содержания
| Файл (новый путь) | Что сделать |
|------|-------------|
| `docs/design/architecture_review.md` | DM завершён (5/5 витрин), пометить выполненные P2; строка 146 — убрать путь к несуществующему `distribution_strategy.md`, оставить как текстовый backlog-пункт |
| `docs/design/db_schema.md` | Добавить DM-слой, убрать выполненный TODO |
| `TESTING.md` | Убрать артефакт «Docker-стенд не запускался» |
| `docs/dag_execution_order.md` | Добавить все 4 DDL DAG-а |
| `docs/reference/pxf_bookings.md` | Исправить нумерацию разделов (7→9→8→10 → последовательную) |
---
## Фаза 5. Обновление индексов и заглушка assignment
### `docs/README.md`
Переписать структуру: разделы по каталогам (`design/`, `reference/`, `plans/`,
`archive/`, `assignment/`). Включить ранее пропущенные документы: `db_schema.md`,
`dag_execution_order.md`, `e2e-etl-test-protocol.md`, `agent-dag-testing.md`.
### `README.md` (корень)
Заменить ссылку на `educational-tasks.md` → `docs/assignment/`, проверить остальные.
### `docs/assignment/README.md` (новый файл)
Временная заглушка:
- Указание, что курсовые задания появятся в Этапе 3
- Ссылка на эталонный срез (DAG-и STG→ODS→DDS→DM) для самостоятельного изучения
- Ссылка на `docs/design/assignment_design.md` для менторов
---
## Фаза 6. Финальная проверка
Шаги выполняются строго по порядку:
1. [ ] `make test` — тесты проходят
2. [ ] `make lint` — стиль кода
3. [ ] Удалить пустой каталог `docs/internal/`
4. [ ] Перенести план из `docs/plans/` в `docs/archive/docs_restructuring_plan.md`
5. [ ] grep по `internal/` в живых .md файлах (`rg --glob '!docs/archive/*'`) — нет битых ссылок
6. [ ] grep по `educational-tasks` в живых .md файлах (`rg --glob '!docs/archive/*'`) — нет битых ссылок
---
## Порядок выполнения
Фазы 1→2→3 делаются вместе (иначе ссылки будут битыми).
Фаза 4 — независима, можно параллельно.
Фаза 5 — после всех перемещений.
Фаза 6 — в конце.
---
## Что это даёт следующим этапам
| Этап | Как помогает |
|------|-------------|
| **Этап 2** (подготовка main) | Чистая структура — понятно, что удалять, что оставлять |
| **Этап 3** (ТЗ от аналитика) | Готовый каталог `docs/assignment/` с заглушкой, место для `analyst_spec.md` |
| **Этап 4** (валидационный DAG) | `docs/reference/qa-plan.md` — рядом с другими справочными |
| **Этап 5** (ветка solution) | Архив отделён — не попадёт в ветку solution |
@@ -0,0 +1,91 @@
# Учебные задания по стенду
Этот документ собирает в одном месте задания для менти.
Он разбит на блоки: от архитектуры Greenplum и демо‑БД bookings до реализации аналитических слоев DWH.
Если вы только начинаете, выполняйте задания по порядку.
---
## 1. Greenplum и модель данных (введение)
В следующих заданиях мы будем опираться на демо‑БД bookings (Postgres) и слой STG в Greenplum.
На этом этапе достаточно бегло посмотреть на структуру и понять общую идею, детальная проработка пойдёт позже.
### 1.1. Знакомство с демо‑БД bookings
1. Прочитайте `bookings/README.md` — какие сервисы и команды относятся к демобазе.
2. Поднимите стенд и выполните:
- `make up`
- `make bookings-init`
3. Подключитесь к демобазе:
- `make bookings-psql`
- посмотрите таблицы в схеме `bookings` (например, `\dt bookings.*`).
4. Найдите таблицу `bookings.bookings` и посмотрите на её структуру:
- какие типы колонок используются;
- какие поля выглядят как ключи, даты, суммы.
### 1.2. Знакомство с STG в Greenplum
1. Прочитайте `sql/stg/bookings_ddl.sql` и краткое описание потока `docs/bookings_to_gp_stage.md` (если интересно — `docs/internal/bookings_stg_design.md`).
2. Ответьте себе на вопросы:
- чем внешняя таблица `stg.bookings_ext` отличается от внутренней `stg.bookings`;
- зачем нужны тех.колонки `event_ts`, `_load_ts`, `_load_id`;
- чем слой STG отличается от итоговых витрин (DDS/DM) с точки зрения моделирования.
3. Выполните `make ddl-gp`, затем зайдите в Greenplum (`make gp-psql`) и проверьте наличие схемы и таблиц:
- `\dn` и `\dt stg.*`
- `SELECT * FROM stg.bookings LIMIT 5;` (после запуска соответствующего DAG).
### 1.3. Как генерируются учебные данные bookings
1. Откройте файл `bookings/generate_next_day.sql` и ответьте себе на вопросы:
- с какой даты начинается генерация данных (посмотрите на GUC `bookings.start_date` и переменную `v_start_cfg`);
- сколько дней генерируется при первой установке (переменная `bookings.init_days`);
- что происходит, если таблица `bookings.bookings` уже не пустая.
2. В демобазе (`make bookings-psql`) выполните:
- `SELECT min(book_date), max(book_date) FROM bookings.bookings;`
- затем запустите `make bookings-generate-day` и повторите запрос — как изменился максимальный день?
3. Откройте `sql/src/bookings_generate_day_if_missing.sql` и обратите внимание, что:
- логическая дата запуска DAG (`{{ ds }}`) не влияет на выбор дня генерации;
- скрипт всегда смотрит на `max(book_date)` и добавляет **следующий** день (или несколько стартовых дней, если база пуста).
4. Сделайте вывод: генератор всегда «шагает» по датам вперёд от максимальной даты, поэтому:
- при `make bookings-init` вы получаете готовые данные из seed-дампа (при `make bookings-generate` генератор создаст `BOOKINGS_INIT_DAYS` дней начиная с `BOOKINGS_START_DATE`);
- при последующих вызовах (`make bookings-generate-day` или DAG) добавляется ровно один новый день.
---
## 2. DAG bookings_to_gp_stage (заготовка заданий)
Этот DAG показывает путь данных от демо‑БД bookings в Postgres до сырого слоя STG в Greenplum.
Сейчас он уже реализован как учебный пример, а в будущем вокруг него появятся отдельные задания по моделированию DWH.
### 2.1. Что есть сейчас
1. Откройте `airflow/dags/bookings_to_gp_stage.py`.
2. Найдите в коде ссылки на SQL‑файлы:
- `sql/src/bookings_generate_day_if_missing.sql`
- `sql/stg/bookings_load.sql`
- `sql/stg/bookings_dq.sql`
3. Соотнесите шаги DAG с документом `docs/bookings_to_gp_stage.md`:
- генерация учебного дня в `bookings.bookings`;
- загрузка инкремента в `stg.bookings`;
- проверка количества строк между источником и STG.
4. Обратите внимание, как в DAG используется логическая дата запуска:
- `{{ run_id }}` используется как `_load_id` — метка загрузки в таблице `stg.bookings` для конкретного запуска;
- сами даты данных (какие дни есть в `bookings.bookings`) определяются генератором по `max(book_date)`, а не по `ds`.
На этом этапе достаточно понять общую цепочку. Детальные задания по переработке модели данных и построению ODS/DDS/DM слоёв будут добавлены позже.
### 2.2. Идеи для будущих заданий (черновик)
> Ниже — набросок задач, к которым мы вернёмся, когда базовые темы по Airflow будут освоены.
Планируемые направления:
- Спроектировать модель данных для основных сущностей демобазы bookings (рейсы, билеты, перелёты) в слоях ODS/DDS/DM.
- Реализовать слой ODS поверх STG, аккуратно работая с временными атрибутами и ключами.
- Построить витрины (DM) для типичных аналитических вопросов: загрузка рейсов, выручка по направлениям, динамика бронирований.
- Добавить DAG’и, которые используют `stg.bookings` как источник и строят следующие слои DWH.
- Расширить проверки качества данных для потоков bookings → STG → витрины.
Когда будете готовы к этим темам, вернитесь к этому разделу — он станет основой для следующего «модуля» лабораторных заданий.
@@ -0,0 +1,118 @@
# Унификация нейминга служебных полей в STG
## Контекст
В STG-слое используются legacy-имена служебных полей (`batch_id`, `load_dttm`, `src_created_at_ts`), а начиная с ODS — каноничные (`_load_id`, `_load_ts`, `event_ts`). Студент видит разные имена для одного понятия. Цель — привести STG к канону, убрав расхождение.
> **Breaking change (dev-only).** Это ломающее переименование колонок. Миграционный шаг (ALTER TABLE … RENAME COLUMN) не предусмотрен. DDL-файлы используют `CREATE TABLE IF NOT EXISTS`, поэтому сами по себе они не пересоздадут существующие таблицы с новыми именами колонок. План предполагает заранее пересозданную среду (например, `make down && make up`) или ручной `DROP TABLE` / `DROP SCHEMA` перед `make ddl-gp`. Обратная совместимость не обеспечивается.
## Маппинг
| Legacy (STG сейчас) | Канон (ODS/DDS/DM) |
|---|---|
| `batch_id` | `_load_id` |
| `load_dttm` | `_load_ts` |
| `src_created_at_ts` | `event_ts` |
### Оговорка про `event_ts` в snapshot-справочниках
В транзакционных STG-таблицах (bookings, tickets, flights, segments, boarding_passes) поле `src_created_at_ts` действительно хранит время события из источника — переименование в `event_ts` семантически точно.
В snapshot-справочниках (airports, airplanes, routes, seats) это поле заполняется `now()` при загрузке, т.е. по факту это ещё одно load-time, а не время события. Тем не менее мы сохраняем единое имя `event_ts` как **учебное упрощение** — ради консистентной структуры STG-таблиц. Это зафиксировано как осознанный trade-off: единообразие важнее семантической точности в справочниках. В `naming_conventions.md` нужно добавить соответствующую оговорку (раздел 4, «Time Rule»).
## Что НЕ переименовываем
- PL/pgSQL переменная `v_batch_id` — это локальная переменная, не колонка
- Python-функция `_resolve_stg_batch_id`, переменная `stg_batch_id`, task_id `resolve_stg_batch_id` — это Python/Airflow-идентификаторы
- XCom-ключи, ссылающиеся на task_id
## Порядок выполнения
### Шаг 1: STG DDL (9 файлов)
`sql/stg/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_ddl.sql`
В каждом: `batch_id` → `_load_id`, `load_dttm` → `_load_ts`, `src_created_at_ts` → `event_ts`.
### Шаг 2: STG Load (9 файлов)
`sql/stg/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_load.sql`
INSERT-списки, SELECT, WHERE, комментарии — те же 3 замены.
### Шаг 3: STG DQ (9 файлов)
`sql/stg/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_dq.sql`
WHERE-условия (`batch_id = v_batch_id` → `_load_id = v_batch_id`), RAISE-сообщения, комментарии.
### Шаг 4: ODS Load (9 файлов)
Два подтипа — обрабатывать по-разному.
#### 4a: Транзакционные таблицы (5 файлов)
`sql/ods/{bookings,tickets,flights,segments,boarding_passes}_load.sql`
SELECT из STG: `s.batch_id` → `s._load_id`, `s.load_dttm` → `s._load_ts`, `s.src_created_at_ts` → `s.event_ts`. Убрать лишние алиасы (`s.src_created_at_ts AS event_ts` → просто `s.event_ts`).
#### 4b: Snapshot-справочники (4 файла)
`sql/ods/{airports,airplanes,routes,seats}_load.sql`
Здесь `event_ts` отсутствует в целевой ODS-таблице — менять только ссылки на STG-колонки: `s.batch_id` → `s._load_id`, `s.load_dttm` → `s._load_ts`, `s.src_created_at_ts` → `s.event_ts` (только в ORDER BY / WHERE, где они читают из STG). ODS-колонка `_load_ts` по-прежнему заполняется через `now()`, это не меняется.
### Шаг 5: ODS DQ (9 файлов)
`sql/ods/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_dq.sql`
`WHERE batch_id =` → `WHERE _load_id =`, RAISE-сообщения.
### Шаг 6: DAG-файлы (2 файла)
- `airflow/dags/bookings_to_gp_stage.py` — комментарий про `batch_id`
- `airflow/dags/bookings_to_gp_ods.py` — встроенный SQL-запрос резолвера: все `batch_id` как колонка → `_load_id`, `load_dttm` → `_load_ts`. Python-имена не трогаем.
### Шаг 7: Тесты (3 файла)
- `tests/test_ods_snapshot_integration.py` — inline DDL и INSERT в тестах
- `tests/test_dags_smoke.py` — комментарии
- `tests/test_ods_sql_contract.py` — docstring
### Шаг 8: Документация (~15 файлов)
- `docs/internal/naming_conventions.md` — убрать legacy-исключение (секция 5/STG), убрать переходный маппинг (секция 6), добавить оговорку про `event_ts` в snapshot-справочниках (секция 4)
- `docs/internal/db_schema.md` — описания STG-полей
- `docs/internal/bookings_stg_design.md` — дизайн STG
- `docs/internal/bookings_ods_design.md` — маппинг STG→ODS, SQL-примеры
- `docs/internal/qa-plan.md` — SQL-запросы проверок
- `docs/internal/architecture_review.md` — архитектурные заметки
- `docs/internal/bookings_stg_code_review.md` — код-ревью
- `docs/bookings_to_gp_stage.md` — описание STG DAG, примеры полей
- `docs/bookings_to_gp_ods.md` — описание ODS DAG
- `docs/dag_execution_order.md` — порядок выполнения DAG
- `educational-tasks.md` — учебные задания
- `README.md` — SQL-примеры в README
- `TESTING.md` — чек-лист тестирования
### Шаг 9: Верификация
```bash
# 1. Проверить SQL и Python — не должно быть колонок batch_id
# (допустимы только: v_batch_id, stg_batch_id, resolve_stg_batch_id)
grep -rn 'batch_id' sql/stg/ sql/ods/ airflow/dags/ tests/ --include='*.sql' --include='*.py'
# 2. Ноль совпадений по старым именам в коде
grep -rn 'load_dttm' sql/ airflow/dags/ tests/ --include='*.sql' --include='*.py'
grep -rn 'src_created_at_ts' sql/ airflow/dags/ tests/ --include='*.sql' --include='*.py'
# 3. Проверить документацию — не должно быть старых имён как актуальных
# (допустимы упоминания в историческом контексте)
grep -rn 'batch_id\|load_dttm\|src_created_at_ts' docs/ educational-tasks.md README.md TESTING.md
# 4. Тесты и линтер
make test
make fmt && make lint
```
## Итого: ~55 файлов, ~3 механические замены в каждом
@@ -0,0 +1,88 @@
# План: выравнивание качества документации ETL DAG'ов
## Контекст
Документ `bookings_to_gp_stage.md` — эталон: пошаговый разбор задач, ASCII-граф,
ссылки на SQL-файлы, описание edge cases. Три остальных документа
(`_ods`, `_dds`, `_dm`) значительно беднее. Цель — подтянуть их до того же уровня.
## Единый шаблон секций (целевая структура)
Каждый документ должен содержать:
1. **Заголовок + вводный абзац** (что за DAG, какой слой, зачем)
2. **Что делает DAG** (буллеты, краткое описание)
3. **Что должно быть готово** (prerequisites)
4. **Как запустить** (UI + опциональные параметры)
5. **Граф зависимостей** (ASCII-диаграмма, не буллет-лист)
6. **Как это работает внутри (по шагам)** ← ГЛАВНОЕ ДОБАВЛЕНИЕ
- Пронумерованные шаги: task_id → SQL-файл → что делает → паттерн (SCD1/SCD2/rebuild/HWM)
- Учебные пояснения к нетривиальным паттернам
7. **Как проверить результат** (SQL-запросы)
8. **Типичные ошибки** (уже есть, оставляем)
## Что именно добавить/исправить в каждом документе
### A. `bookings_to_gp_ods.md`
**Текущее состояние:** 95 строк, нет пошагового разбора, нет ASCII-графа, нет SQL-путей.
| # | Что сделать | Детали |
|---|-------------|--------|
| A1 | ASCII-граф зависимостей | Заменить буллет-лист на диаграмму (как в stage). Показать параллельные ветки airports/airplanes, схождение на routes/seats, цепочку flights→segments→boarding_passes |
| A2 | Секция "Как это работает внутри" | 10 шагов: resolve_stg_batch_id (Python, INTERSECT-логика), затем 9 пар load→dq с указанием SQL-файлов |
| A3 | Пояснить паттерн SCD1 UPSERT | Кратко: TEMP TABLE → UPDATE (IS DISTINCT FROM) → INSERT. Одного абзаца достаточно, потом ссылка "паттерн одинаков для всех 9 таблиц" |
| A4 | Описать разницу snapshot vs HWM | Snapshot-справочники фильтруются по `stg_batch_id`; транзакционные таблицы — по HWM (`_load_ts`). Объяснить почему (чтобы не терять инкременты при повторных запусках STG) |
| A5 | Edge case: пустой батч | Для инкрементальных таблиц допустим; для snapshot — нет |
**Ожидаемый объём:** ~140–160 строк.
### B. `bookings_to_gp_dds.md`
**Текущее состояние:** 73 строки — самый бедный документ. Нет пошаговости, нет ASCII-графа, SCD2 не объяснён.
| # | Что сделать | Детали |
|---|-------------|--------|
| B1 | ASCII-граф зависимостей | calendar → параллельно 4 SCD1-измерения + dim_routes (после airports+airplanes) → fact (после всех dims) → summary |
| B2 | Секция "Как это работает внутри" | 8 шагов: calendar (rebuild), 4×SCD1-измерения, dim_routes (SCD2), fact_flight_sales, summary |
| B3 | Объяснить SCD2 для dim_routes | Учебный блок: hashdiff (MD5), закрытие старых версий, вставка новых, point-in-time valid_from. Это ключевой паттерн DDS — заслуживает 10–15 строк |
| B4 | Объяснить Phase 3 (денормализация) | Обновление SCD1-атрибутов (города, модель) во ВСЕХ версиях dim_routes. Зачем: чтобы не хранить устаревшие названия городов |
| B5 | Объяснить late-arriving dimensions | LEFT JOIN в fact_flight_sales: факт может прийти раньше справочника. 3–5 строк |
| B6 | Объяснить point-in-time join | Как факт привязывается к правильной версии SCD2-маршрута по дате рейса |
| B7 | Edge case: генерация SK | MAX() + ROW_NUMBER() безопасна только при concurrency=1 |
**Ожидаемый объём:** ~160–180 строк.
### C. `bookings_to_gp_dm.md`
**Текущее состояние:** 80 строк. ASCII-граф есть (хорошо!), описание стратегий загрузки есть (хорошо!), но нет пошагового разбора с SQL-файлами.
| # | Что сделать | Детали |
|---|-------------|--------|
| C1 | Секция "Как это работает внутри" | 5 шагов (по одному на витрину) + start_dm + finish_dm_summary. Для каждой витрины: task_id → SQL-файл → паттерн → зерно (grain) |
| C2 | Расширить описание sales_report | HWM-паттерн: какие даты пересчитываются, NULLIF-guard для boarding_rate, автоматическая "догонка" при первичной загрузке |
| C3 | Расширить описание route_performance | Почему Full Rebuild: AO Column (нет UPDATE/DELETE), таблица маленькая. 3-шаговый паттерн: TRUNCATE → агрегация по route_bk → JOIN с текущей версией SCD2 |
| C4 | Добавить grain для каждой витрины | sales_report: (flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk); route_performance: route_bk; и т.д. |
| C5 | Добавить SQL-пути к задачам | Сейчас нигде не указаны пути к SQL-файлам |
**Ожидаемый объём:** ~140–160 строк.
### D. Мелкие правки в `bookings_to_gp_stage.md` (опционально)
| # | Что сделать | Детали |
|---|-------------|--------|
| D1 | Убедиться в консистентности шаблона | Если в ходе работы над ODS/DDS/DM выработается чуть лучшая структура — привести stage к тому же формату (только структурные правки, контент не менять) |
## Порядок работы
1. **ODS** (средняя сложность, знакомый паттерн SCD1)
2. **DDS** (наибольшая учебная ценность — SCD2, late-arriving dims)
3. **DM** (наименьший объём правок — ASCII-граф уже есть)
4. **Stage** — только если нужна косметика для консистентности
## Принципы
- Не раздувать: целевой объём каждого документа — 140–180 строк (stage = 157).
- Учебная ценность > полнота: объяснять «почему», а не перечислять все колонки.
- SQL-пути обязательны — это главный навигационный инструмент для студента.
- Один паттерн объясняем один раз подробно, дальше ссылаемся: "паттерн аналогичен X".
@@ -0,0 +1,503 @@
# Plan: Перенос STG в эталон, ODS airplanes+seats — студенту
> Версия: 4 (после третьего ревью ChatGPT 5.4)
> Дата: 2026-03-11
> **Статус выполнения (2026-03-12):**
> - Секция «Сейчас (на chore/bookings-etl)» — ✅ выполнена (п.1, п.6, п.7, п.9)
> - Секция «Этап 3 (валидационный DAG)» — ✅ выполнена (см. `docs/archive/2026-03-12_validation-dag.md`)
> - Секция «Этап 4 (подготовка main + solution)» — ⬜ не выполнена.
> Детальные спецификации кода (п.2–5) и документации (п.8, п.10–11) остаются
> актуальным справочником. Порядок выполнения и стратегия веток —
> в новом плане `docs/plans/2026-03-12_main-solution-split.md`.
## Контекст и мотивация
### Обнаруженная проблема
При подготовке к Этапу 4 (раскладка по веткам main / solution) обнаружен **конфликт
между текущим дизайном БД** (`docs/design/db_schema.md`, `docs/design/bookings_dds_design.md`)
**и планом курсового задания** (`docs/design/assignment_design.md`).
Суть конфликта: эталонный пайплайн (который должен работать «из коробки» на main-ветке
для студента) **неработоспособен**, если студент ещё не реализовал ни одного задания.
Три DDS-измерения (`dim_routes`, `dim_passengers`, `dim_airplanes`) отнесены к заданию
студента, но эталонная фактовая таблица `fact_flight_sales` зависит от них через
цепочку LEFT JOIN. Без этих измерений факт загружается с массовыми NULL в ключевых FK,
а DQ-проверки блокируют pipeline. Эталонные витрины (`sales_report`, `airport_traffic`)
становятся бесполезными.
Проблема усугубляется архитектурой batch resolver ODS DAG и тестовыми контрактами,
которые требуют данных во **всех** snapshot-таблицах STG.
Ниже — детальный разбор и выбранное решение.
### Фактовая таблица: зависимость от студенческих измерений
`fact_flight_sales` зависит от **всех 7 измерений** DDS,
включая 3 студенческих: `dim_routes`, `dim_passengers`, `dim_airplanes`.
Критическая цепочка: аэропорты в факте разрешаются **через `dim_routes`**:
```
flt.route_no → dim_routes.route_bk → rte.departure_airport → dim_airports.airport_bk → airport_sk
```
Если `dim_routes` пуст (студент ещё не реализовал), то **NULL** получают не только
`route_sk`, но и оба `airport_sk` и `airplane_sk`. Эталонная витрина `sales_report`
становится бесполезной.
Причина: `ods.flights` содержит только `route_no`, но не `departure_airport` /
`arrival_airport` / `aircraft_code`. Эти поля доступны только через таблицу `routes`.
### Почему нельзя просто использовать DDL-заглушки
Если `dim_routes` — пустая заглушка, все LEFT JOIN через неё возвращают NULL.
Факт загружается, но:
- `departure_airport_sk = NULL` → `sales_report` не может группировать по аэропортам
- `arrival_airport_sk = NULL` → `airport_traffic` не может считать трафик
- UPDATE факта **не перезаписывает SK** (by design, строка 4-5 load-скрипта) →
NULL-и остаются навсегда до TRUNCATE + перезагрузки
### Дополнительные архитектурные ограничения (из ревью)
1. **Fact DQ блокирует загрузку** (`fact_flight_sales_dq.sql`):
- Строка 71: `passenger_sk IS NULL` → **hard fail** (100% NULL → exception)
- Строка 98: `route_sk / airplane_sk IS NULL` → при >1% строк → **exception**
- На main с пустыми студенческими dims — 100% NULL → pipeline падает
2. **Batch resolver ODS DAG** (`bookings_to_gp_ods.py:59-77`):
- `INTERSECT` по `stg.airports`, `stg.airplanes`, `stg.routes`, `stg.seats`
- Если **любая** STG-таблица пуста — общего batch нет → весь ODS pipeline падает
- Контракт зафиксирован тестом `test_ods_sql_contract.py:30`
### Выбранное решение
**Весь STG-слой — эталонный** (все 9 таблиц загружаются reference-кодом).
Это гарантирует:
- Batch resolver всегда находит согласованный batch (все 4 snapshot-таблицы заполнены)
- DAG-зависимости и smoke-тесты STG не требуют изменений
- Никакого расхождения batch-контракта между main и solution
**ODS: `airplanes` + `seats` остаются заданием студента** (TRUNCATE+INSERT практика).
**DDS: `dim_airplanes`, `dim_passengers`, `dim_routes`** — задание студента (DDL-заглушки).
**Fact lookup**: аэропорты через `ods.routes` (эталон); `airplane_sk` остаётся
point-in-time через `dim_routes` (NULL на main — допустимо).
**Fact DQ**: на main ослабить проверки студенческих SK.
### Что теряет студент (и почему это приемлемо)
| Потеря | Компенсация |
|--------|-------------|
| STG-практика (PXF external tables) | Изучает эталонный STG-код; отдельный PXF-практикум в бэклоге (`assignment_design.md`, раздел 6) |
| `ods.routes` (TRUNCATE+INSERT) | Остаются `ods.airplanes` + `ods.seats` — тот же паттерн |
Ключевые элементы задания **сохранены**:
- ODS: 2 таблицы (airplanes, seats) — практика TRUNCATE+INSERT
- DDS: `dim_airplanes` (SCD1), `dim_passengers` (SCD1), **`dim_routes` (SCD2)** — ключевой вызов
- DM: 4 витрины разной сложности (от простой к сложной)
### Пересчёт факта после реализации студентом
После реализации всех измерений студенту нужно:
1. Запустить загрузку DDS-измерений (dim_airplanes, dim_passengers, dim_routes)
2. `TRUNCATE dds.fact_flight_sales;`
3. Перезапустить загрузку факта → теперь все SK заполнены
4. Перезапустить DM-витрины
Это стандартная практика при late-arriving dimensions и хорошая обучающая точка.
### airport_traffic — студенческое задание
`airport_traffic` архитектурно не зависит от студенческих dims (использует только
`calendar_sk`, `departure_airport_sk`, `arrival_airport_sk`). Но это **не значит**,
что витрина готова: SQL витрины пишет студент. Данные в факте есть (эталон),
студент пишет агрегирующий SQL. Статус: **задание студента** (как и остальные 3 DM).
---
## Матрица зависимостей DM-витрин
| DM витрина | calendar | airports | tariffs | routes | passengers | airplanes | Работает без студ. dims? |
|---|---|---|---|---|---|---|---|
| `sales_report` (эталон) | + | + | + | — | — | — | **ДА** |
| `airport_traffic` (студент) | + | + | — | — | — | — | Данные есть, SQL пишет студент |
| `route_performance` (студент) | + | — | — | **+** | — | — | Нет |
| `monthly_overview` (студент) | + | — | — | **+** | **+** | **+** | Нет |
| `passenger_loyalty` (студент) | + | — | + | **+** | **+** | — | Нет |
---
## Изменения (код)
### 1. `sql/dds/fact_flight_sales_load.sql` — lookup аэропортов через ods.routes
**Ветка:** chore/bookings-etl (сейчас) + main (Этап 4)
**Текущее состояние** (строки 51-62):
```sql
LEFT JOIN dds.dim_routes AS rte
ON rte.route_bk = flt.route_no
AND flt.scheduled_departure::DATE >= rte.valid_from
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
...
LEFT JOIN dds.dim_airports AS dep
ON dep.airport_bk = rte.departure_airport -- через dim_routes!
LEFT JOIN dds.dim_airports AS arr
ON arr.airport_bk = rte.arrival_airport -- через dim_routes!
LEFT JOIN dds.dim_airplanes AS ap
ON ap.airplane_bk = rte.airplane_code -- через dim_routes!
```
**После изменения:**
```sql
-- Учебный комментарий: Airport lookup через ods.routes (эталонный справочник),
-- а не через dds.dim_routes (студенческое задание SCD2).
-- Это архитектурное решение: эталонный пайплайн работает независимо от студенческого кода.
-- ROW_NUMBER по validity DESC: выбираем актуальную версию расписания маршрута.
-- Аэропорты вылета/прилёта одинаковы во всех версиях одного route_no.
LEFT JOIN (
SELECT route_no, departure_airport, arrival_airport
FROM (
SELECT route_no, departure_airport, arrival_airport,
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
FROM ods.routes
) ranked
WHERE rn = 1
) AS ods_rte ON ods_rte.route_no = flt.route_no
LEFT JOIN dds.dim_routes AS rte
ON rte.route_bk = flt.route_no
AND flt.scheduled_departure::DATE >= rte.valid_from
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
...
LEFT JOIN dds.dim_airports AS dep
ON dep.airport_bk = ods_rte.departure_airport -- через ods.routes (эталон)
LEFT JOIN dds.dim_airports AS arr
ON arr.airport_bk = ods_rte.arrival_airport -- через ods.routes (эталон)
LEFT JOIN dds.dim_airplanes AS ap
ON ap.airplane_bk = rte.airplane_code -- через dim_routes (point-in-time, как прежде)
```
**Почему аэропорты через ods.routes, а airplane через dim_routes:**
- **Аэропорты**: `departure_airport` и `arrival_airport` одинаковы во всех версиях
одного `route_no` (маршрут SVO→LED всегда SVO→LED). Безопасно брать из ODS.
Это даёт эталонным витринам (`sales_report`, `airport_traffic`) корректные `airport_sk`
даже без студенческого `dim_routes`.
- **Самолёт**: `airplane_code` теоретически может отличаться между версиями маршрута.
Текущий контракт DDS (`bookings_dds_design.md:499`) фиксирует, что `airplane_sk`
приходит из **той же point-in-time версии** маршрута, что и `route_sk`.
Менять эту семантику — изменение модели данных, а не стабилизация.
На main `airplane_sk` будет NULL (dim_routes — заглушка). Это допустимо:
ни `sales_report`, ни `airport_traffic` не используют `airplane_sk`.
### 2. `sql/dds/fact_flight_sales_dq.sql` — ослабить проверки студенческих SK (только main)
**Ветка:** только main (Этап 4). На solution-ветке полные проверки сохраняются.
Изменения:
- **Строки 65-75** (`passenger_sk IS NULL`): заменить `RAISE EXCEPTION` на `RAISE NOTICE`.
На main `dim_passengers` — заглушка, 100% строк будут с `passenger_sk IS NULL`.
Любой порог (даже >1%) даст exception. **Только логирование, без блокировки.**
- **Строки 89-108** (route-related FK): разделить на два блока:
**Блок A — эталонные FK (проверка с порогом 1%, как calendar_sk):**
```sql
-- departure_airport_sk и arrival_airport_sk заполняются через ods.routes (эталон).
-- NULL здесь — аномалия данных (пропущен маршрут в ODS), а не отсутствие студенческого кода.
-- Порог 1% — защита от единичных аномалий источника (аналогично calendar_sk).
SELECT COUNT(*) INTO v_null_airport
FROM dds.fact_flight_sales
WHERE departure_airport_sk IS NULL OR arrival_airport_sk IS NULL;
IF v_null_airport > 0 THEN
IF v_null_airport * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN
RAISE EXCEPTION 'DQ FAILED: NULL airport_sk: % (>1%%)', v_null_airport;
ELSE
RAISE NOTICE 'DQ WARNING: NULL airport_sk: % (<=1%%, допустимо)', v_null_airport;
END IF;
END IF;
```
**Блок B — студенческие FK (только логирование, RAISE NOTICE):**
```sql
-- route_sk и airplane_sk будут NULL, пока студент не реализует dim_routes.
-- Не блокируем pipeline.
SELECT COUNT(*) INTO v_null_student
FROM dds.fact_flight_sales
WHERE route_sk IS NULL OR airplane_sk IS NULL;
IF v_null_student > 0 THEN
RAISE NOTICE 'DQ INFO: студенческие SK (route/airplane) NULL: %. '
'После реализации dim_routes: TRUNCATE fact → перезагрузка.',
v_null_student;
END IF;
```
Итоговое правило на main:
- `departure_airport_sk`, `arrival_airport_sk` — **проверка с порогом 1%** (эталон, через ods.routes; >1% → EXCEPTION, <=1% → NOTICE)
- `passenger_sk`, `route_sk`, `airplane_sk` — **только RAISE NOTICE** (студенческие заглушки, 100% NULL допустимо)
```sql
-- Учебный комментарий: route_sk, airplane_sk и passenger_sk будут NULL,
-- пока вы не реализуете соответствующие DDS-измерения.
-- После реализации: TRUNCATE dds.fact_flight_sales → перезагрузка → все SK заполнены.
-- Полную версию DQ (с блокировкой) см. в ветке solution.
```
### 3. ODS Routes DQ: убрать RI-проверку airplane_code (только main)
**Ветка:** только main (Этап 4). На solution-ветке полные проверки сохраняются.
> **Почему только ODS, не STG?** Весь STG — эталон, `stg.airplanes` всегда заполнен,
> `stg/routes_dq.sql` RI-проверка к `stg.airplanes` проходит штатно. Менять STG не нужно.
> На уровне ODS `ods.airplanes` — студенческая заглушка (пустая), поэтому
> `ods/routes_dq.sql` RI-проверка к `ods.airplanes` упадёт.
**Файл:**
- `sql/ods/routes_dq.sql` — удалить блок строк 143-156 (RI к `ods.airplanes`)
Добавить комментарий:
```sql
-- Учебный комментарий: проверка RI airplane_code → ods.airplanes не выполняется,
-- т.к. таблица ods.airplanes реализуется студентом. После реализации — раскомментируйте
-- (см. ветку solution для полной версии).
```
### 4. ODS DAG-зависимость: убрать airplanes → routes (только main)
**Ветка:** только main (Этап 4)
> **Почему только ODS, не STG?** STG DAG не меняется: `stg.airplanes` — эталон,
> барьер `check_airplanes_dq >> load_routes_to_stg` работает штатно.
> На уровне ODS `dq_ods_airplanes` — заглушка (`SELECT 1;`), формально проходит,
> но зависимость вводит в заблуждение. Убираем для ясности.
`airflow/dags/bookings_to_gp_ods.py` (строка 266):
```python
# Было:
[dq_ods_airports, dq_ods_airplanes] >> load_ods_routes
# Стало:
dq_ods_airports >> load_ods_routes
```
### 5. Заглушки для студенческих файлов (только main)
**Ветка:** только main (Этап 4)
#### STG-слой: весь код остаётся (эталон), заглушки не нужны
Все 9 STG-таблиц загружаются эталонным кодом. Batch resolver работает без изменений.
#### ODS-слой: заглушки для airplanes + seats
| Файл | main (студент) | solution |
|------|----------------|----------|
| `sql/ods/airplanes_ddl.sql` | Полный DDL (таблица нужна) | Тот же |
| `sql/ods/airplanes_load.sql` | Заглушка: `SELECT 1; -- TODO` | Полная реализация |
| `sql/ods/airplanes_dq.sql` | Заглушка: `SELECT 1;` | Полная реализация |
| `sql/ods/seats_ddl.sql` | Полный DDL | Тот же |
| `sql/ods/seats_load.sql` | Заглушка | Полная реализация |
| `sql/ods/seats_dq.sql` | Заглушка | Полная реализация |
#### DDS-слой: заглушки для 3 измерений
| Файл | main (студент) | solution |
|------|----------------|----------|
| `sql/dds/dim_routes_ddl.sql` | Полный DDL (нужен для LEFT JOIN) | Тот же |
| `sql/dds/dim_routes_load.sql` | Заглушка: `SELECT 1; -- TODO: реализуйте SCD2` | Полная реализация |
| `sql/dds/dim_routes_dq.sql` | Заглушка: `SELECT 1;` | Полная реализация |
| `sql/dds/dim_passengers_ddl.sql` | Полный DDL | Тот же |
| `sql/dds/dim_passengers_load.sql` | Заглушка | Полная реализация |
| `sql/dds/dim_passengers_dq.sql` | Заглушка | Полная реализация |
| `sql/dds/dim_airplanes_ddl.sql` | Полный DDL | Тот же |
| `sql/dds/dim_airplanes_load.sql` | Заглушка | Полная реализация |
| `sql/dds/dim_airplanes_dq.sql` | Заглушка | Полная реализация |
#### DM-слой: заглушки для 4 витрин
Аналогично: DDL остаётся, load/dq заменяются заглушками.
Файлы: `airport_traffic`, `route_performance`, `monthly_overview`, `passenger_loyalty`.
---
## Изменения (документация)
### 6. `docs/assignment/analyst_spec.md`
- **Удалить** разделы 1.1-1.3 (все STG-задания) — весь STG теперь эталон
- **Удалить** разделы 2.3 (`ods.routes`) — routes теперь эталон
- **Обновить** рекомендуемый порядок: начинается с ODS (airplanes, seats)
- **Добавить** мотивационную секцию «Почему STG уже реализован»:
- Эталонный пайплайн требует согласованного batch по всем snapshot-таблицам
- Без данных в STG невозможна загрузка ODS и далее по цепочке
- Студент изучает эталонный STG-код как образец
- **Добавить** секцию «Пересчёт факта после реализации измерений»:
- Инструкция: TRUNCATE fact + re-run после реализации всех DDS-измерений
- Педагогическая ценность: late-arriving dimensions, dependency management
### 7. `docs/design/assignment_design.md`
- **Обновить** таблицу «Эталонные таблицы»: добавить весь STG + ods.routes
- **Обновить** таблицу «Задание студенту»: убрать STG целиком, ODS оставить airplanes+seats
- **Обновить** рекомендуемый порядок: начинается с ODS
- **Обновить** структуру валидационного DAG (раздел 4): убрать `validate_stg` секцию
### 8. `docs/design/bookings_dds_design.md`
- **Строки 463, 468** (SQL-скелет факта): обновить JOIN-блок —
`dep.airport_bk` и `arr.airport_bk` теперь через `ods_rte`, а не через `rte`;
`ap.airplane_bk` остаётся через `rte` (point-in-time)
- **Строка 499-501** (текстовое пояснение): обновить —
`departure_airport_sk` и `arrival_airport_sk` берутся из `ods.routes` (эталон);
`airplane_sk` остаётся point-in-time через `dim_routes`
- **Строка 558** (passenger_sk = 0): убрать утверждение о нулевой толерантности —
на main `passenger_sk` будет 100% NULL (dim_passengers — заглушка)
- **Строка 559** (DQ текстовое описание): обновить — на main student SK
(`passenger_sk`, `route_sk`, `airplane_sk`) не блокируют pipeline;
`airport_sk` проверяются с порогом 1%
- **Строка 741** (DQ SQL-пример): обновить встроенный SQL — разделить route-related
блок на эталонный (airport_sk, порог 1%) и студенческий (route_sk, airplane_sk, NOTICE)
### 9. `docs/design/db_schema.md`
- Пометить весь STG и `ods.routes` как эталонные (не студенческие)
- Строка 3: «Все слои реализованы» — уточнить, что на main ODS airplanes/seats,
DDS student dims и DM student vitrines — заглушки
- Строка 253: DQ-контракт «SQL-скрипты с RAISE EXCEPTION» — добавить оговорку,
что на main студенческие DQ-скрипты являются заглушками
### 10. Тесты
- `tests/test_dags_smoke.py` строки 242-243:
На main убрать assert барьера `dq_ods_airplanes → dq_ods_routes` (ODS routes
не зависит от ODS airplanes на main). STG-барьеры (строки 123-124) **не трогаем**:
STG целиком эталонный, барьер `check_airplanes_dq → check_routes_dq` работает.
- `tests/test_ods_sql_contract.py` строка 6:
На main изменить `SNAPSHOT_ENTITIES` — исключить студенческие ODS:
```python
# Было:
SNAPSHOT_ENTITIES = ("airports", "airplanes", "routes", "seats")
# Стало (main):
SNAPSHOT_ENTITIES = ("airports", "routes")
```
Тесты `test_snapshot_load_scripts_use_truncate` (строка 13) и
`test_snapshot_dq_checks_extra_keys` (строка 22) проверяют паттерны TRUNCATE+INSERT
и `v_extra_keys_count` в SQL-файлах. Заглушки `SELECT 1;` не содержат этих паттернов →
тесты упадут для airplanes/seats. Исключение из `SNAPSHOT_ENTITIES` решает проблему.
Batch resolver тест (строка 30) проверяет DAG-код (не SQL-файлы) — без изменений.
### 11. Прочие документы
- `docs/design/PRD.md` — обновить чеклист (раздел 10)
- `docs/design/bookings_ods_design.md` — комплексное обновление:
- Строка 29, 515, 534: пометить `airplanes` и `seats` как студенческие
(на main — заглушки; STG-уровень — эталон, ODS load/dq — студент)
- Строки 521, 523: скорректировать общий DQ-контракт — на main `airplanes_dq.sql`
и `seats_dq.sql` являются заглушками, а не полноценными RAISE EXCEPTION скриптами
- Строка 620: обновить DAG-граф — routes больше не зависит от airplanes на ODS
- Строка 634: убрать зависимость routes от airplanes в описании FK
- `docs/bookings_to_gp_ods.md` — обновить описание
- `docs/bookings_to_gp_dds.md`:
- Строки 111-113: убрать гарантию отсутствия NULL SK в штатном режиме —
на main student SK (`passenger_sk`, `route_sk`, `airplane_sk`) будут NULL
- Строка 116: обновить DQ-семантику — student SK не блокируют (NOTICE);
airport_sk — порог 1%
- Строка 126: обновить описание lookup (airports через ods.routes)
- `docs/reference/qa-plan.md`:
- Строка 80: «ODS: все 9 таблиц не пустые» — на main `ods.airplanes` и `ods.seats`
будут пустыми by design (студенческие заглушки). Уточнить формулировку.
- `docs/e2e-etl-test-protocol.md` — обновить контекст
---
## Порядок выполнения
### Сейчас (на chore/bookings-etl)
1. **Код**: изменить `fact_flight_sales_load.sql` (lookup через ods.routes) — п.1
2. **Документация**: обновить `analyst_spec.md` — п.6
3. **Документация**: обновить `assignment_design.md` — п.7
4. **Документация**: обновить `db_schema.md` — п.9
5. **Тестирование**: `make test` + ручная проверка SQL
6. **Коммит**
### Этап 3 (валидационный DAG)
- Проектировать с учётом DDL-заглушек
- `validate_stg` секцию **не делать** (STG — эталон)
- `check_dim_routes_scd2` — активный тест (мутация ODS → проверка → откат)
### Этап 4 (подготовка main + solution)
- Fact DQ: ослабить проверки student SK (main only) — п.2
- Routes DQ: убрать airplane RI (main only) — п.3
- DAG-зависимости: убрать airplanes → routes (main only) — п.4
- Заглушки: ODS (airplanes, seats) + DDS (3 dims) + DM (4 vitrines) — п.5
- Тесты: обновить smoke-тесты (main only) — п.10
- Документация: bookings_dds_design, PRD, прочее — п.8, п.11
---
## Верификация
### После п.1 (fact_flight_sales fix)
```bash
make test # smoke-тесты DAG
```
Ручная проверка (при поднятом стенде):
> **Важно:** UPDATE факта не перезаписывает dimension SK (by design, строка 4-5).
> Уже загруженные строки сохранят старые значения `airport_sk` (через dim_routes).
> Для полной проверки новой логики нужен **TRUNCATE + перезагрузка**:
```sql
TRUNCATE dds.fact_flight_sales;
-- Перезапустить DDS DAG (или только task load_dds_fact_flight_sales)
-- Проверка: airport_sk заполнены (через ods.routes), route_sk тоже (dim_routes заполнен на этой ветке)
SELECT
COUNT(*) AS total,
COUNT(departure_airport_sk) AS has_dep_sk,
COUNT(arrival_airport_sk) AS has_arr_sk,
COUNT(route_sk) AS has_route_sk
FROM dds.fact_flight_sales;
```
### После Этапа 4 (main branch)
1. `make up` → `make ddl-gp` → запустить STG+ODS+DDS DAG-и
2. Проверить: `sales_report` содержит данные с корректными аэропортами
3. Проверить: студенческие ODS/DDS-таблицы пусты (заглушки)
4. Fact DQ проходит (student SK = NULL, но DQ не блокирует)
5. Реализовать один студенческий dim → TRUNCATE fact → re-run → проверить SK
---
## Ключевые файлы
| Файл | Роль | Ветка |
|------|------|-------|
| `sql/dds/fact_flight_sales_load.sql` | Главное изменение: airport lookup через ods.routes | обе |
| `sql/dds/fact_flight_sales_dq.sql` | Student SK: EXCEPTION → NOTICE | main only |
| `sql/ods/routes_dq.sql` | Убрать RI airplane_code → ods.airplanes | main only |
| `airflow/dags/bookings_to_gp_ods.py` | DAG: убрать airplanes → routes | main only |
| `tests/test_ods_sql_contract.py` | SNAPSHOT_ENTITIES: убрать airplanes, seats | main only |
| `tests/test_dags_smoke.py` | ODS-барьер airplanes → routes | main only |
| `docs/assignment/analyst_spec.md` | Убрать STG-задания, убрать ods.routes | обе |
| `docs/design/assignment_design.md` | Обновить таблицы эталон/задание | обе |
| `docs/design/bookings_dds_design.md` | Обновить описание fact lookup | обе |
| `docs/design/bookings_ods_design.md` | Убрать зависимость routes от airplanes | main only |
| `docs/design/db_schema.md` | Пометить STG + ods.routes как эталон | обе |
> **STG не трогаем**: `sql/stg/routes_dq.sql`, `bookings_to_gp_stage.py`,
> STG smoke-тесты — без изменений. Весь STG эталонный, зависимости работают штатно.
+999
View File
@@ -0,0 +1,999 @@
# План: Валидационный DAG `bookings_validate`
## Контекст и мотивация
### Проблема
Студент реализует 9 объектов (2 ODS, 3 DDS-измерения, 4 DM-витрины) и **не имеет
автоматической обратной связи** — правильно ли работает его код. Сейчас единственный
способ проверки — ручные SQL-запросы и визуальный контроль данных в таблицах.
Это приводит к типичным ошибкам, которые студент не замечает:
| Ошибка | Где проявляется | Когда обнаруживается |
|--------|-----------------|----------------------|
| NULL в PK | ODS/DDS | Только при построении витрины (неожиданные NULL в агрегатах) |
| Дубли по BK | DDS dim_passengers | Факт раздувается, витрины считают неверно |
| SCD2 не закрывает версии | DDS dim_routes | `valid_to` всегда NULL → point-in-time JOIN перестаёт работать |
| «Дыры» в SCD2-интервалах | DDS dim_routes | `sales_report` теряет строки за «пустые» даты |
| TRUNCATE+INSERT не вытащил все строки | ODS airplanes/seats | DDS-измерение неполное → NULL в факте |
| Витрина пуста при непустом источнике | DM | Видно сразу, но причина непонятна |
Без валидационного DAG студент узнаёт о проблеме **через 2-3 слоя** — на этапе DM-витрин,
где отладка многократно сложнее.
### Решение
**Отдельный DAG `bookings_validate`** — набор проверок, сгруппированных по слоям.
Студент запускает его вручную (trigger в Airflow UI) после реализации заданий.
Каждый таск — одна проверка, с дружелюбным сообщением об ошибке и подсказкой,
что делать дальше.
### Педагогическая ценность
1. **Практика чтения логов Airflow** — студент учится находить ошибки в логах тасков
2. **Инкрементальная обратная связь** — можно запускать после каждого шага, не дожидаясь
реализации всех заданий
3. **Паттерн DQ в пайплайне** — студент видит, как устроены production-проверки качества данных
4. **Активный тест SCD2** — единственный способ убедиться, что SCD2-логика действительно
работает (справочник `routes` в демо-базе статичен)
### Дизайн-решения
| Решение | Обоснование |
|---------|-------------|
| Отдельный DAG (не часть основного пайплайна) | Запускается по желанию, не блокирует основную загрузку |
| `schedule=None` (ручной триггер) | Студент запускает, когда готов проверить свой код |
| `PostgresOperator` + SQL-скрипты | Единый паттерн с основными DAG-ами |
| SQL в `sql/validate/` | Отдельный каталог — валидация не смешивается с DQ пайплайна |
| `DO $$ ... RAISE EXCEPTION ... $$` | Тот же паттерн, что в эталонных DQ-скриптах |
| TaskGroup по слоям | Студент видит, на каком слое проблема |
| Проверки «существует + не пуста» для DM | Минимально необходимо; бизнес-инварианты студент добавит сам в DQ витрин |
---
## Структура DAG
```
bookings_validate
├── validate_ods (TaskGroup)
│ ├── check_ods_airplanes_rowcount
│ ├── check_ods_seats_rowcount
│ ├── check_ods_no_dup_bk
│ └── check_ods_no_null_pks
├── validate_dds (TaskGroup)
│ ├── check_dim_airplanes_exists
│ ├── check_dim_passengers_exists
│ ├── check_dim_passengers_no_dup_bk
│ ├── check_dim_routes_exists
│ ├── check_dim_routes_scd2 ← активный тест!
│ └── check_dim_routes_no_gaps
└── validate_dm (TaskGroup)
├── check_airport_traffic_exists
├── check_route_performance_exists
├── check_monthly_overview_exists
└── check_passenger_loyalty_exists
```
### Зависимости между группами
Нет жёстких зависимостей. Все три группы запускаются параллельно — студент может
реализовать только ODS и увидеть зелёные чеки для `validate_ods`, пока DDS/DM ещё
красные. Это даёт инкрементальную обратную связь.
Внутри каждой группы таски также параллельны (независимые проверки).
---
## Детализация проверок
### ODS: `check_ods_airplanes_rowcount`
**Файл:** `sql/validate/ods_airplanes_rowcount.sql`
**Логика:** ODS загружает один конкретный snapshot-батч из STG (по `_load_id`).
STG — append-only: содержит историю всех загрузок. Поэтому сравнивать ODS
со всей STG некорректно — нужно проверять точное множество BK для того батча,
который ODS фактически загрузил.
Определяем батч: `_load_id` из `ods.airplanes` (единственный, т.к. TRUNCATE+INSERT).
Затем проверяем, что множество BK в ODS = множество BK в STG для этого батча.
```sql
DO $$
DECLARE
v_batch_count BIGINT;
v_batch TEXT;
v_ods_count BIGINT;
v_missing_in_ods BIGINT;
v_extra_in_ods BIGINT;
BEGIN
-- ODS пуста?
SELECT COUNT(*) INTO v_ods_count FROM ods.airplanes;
IF v_ods_count = 0 THEN
RAISE EXCEPTION 'FAILED: ods.airplanes пуста. Реализуйте загрузку: sql/ods/airplanes_load.sql';
END IF;
-- Инвариант: ODS после TRUNCATE+INSERT содержит ровно один _load_id
SELECT COUNT(DISTINCT _load_id) INTO v_batch_count FROM ods.airplanes;
IF v_batch_count <> 1 THEN
RAISE EXCEPTION 'FAILED: ods.airplanes содержит % разных _load_id (ожидается 1 после TRUNCATE+INSERT). Проверьте, что load начинается с TRUNCATE.', v_batch_count;
END IF;
-- Определяем батч, из которого загружена ODS
SELECT DISTINCT _load_id INTO v_batch FROM ods.airplanes;
-- BK есть в STG-батче, но нет в ODS (потеряны при загрузке)
SELECT COUNT(*) INTO v_missing_in_ods
FROM (
SELECT DISTINCT airplane_code FROM stg.airplanes WHERE _load_id = v_batch
) AS stg_bk
WHERE NOT EXISTS (
SELECT 1 FROM ods.airplanes AS o WHERE o.airplane_code = stg_bk.airplane_code
);
IF v_missing_in_ods > 0 THEN
RAISE EXCEPTION 'FAILED: % самолётов из STG-батча (%) отсутствуют в ods.airplanes. Проверьте логику TRUNCATE+INSERT.', v_missing_in_ods, v_batch;
END IF;
-- BK есть в ODS, но нет в STG-батче (откуда взялись?)
SELECT COUNT(*) INTO v_extra_in_ods
FROM (
SELECT DISTINCT airplane_code FROM ods.airplanes
) AS ods_bk
WHERE NOT EXISTS (
SELECT 1 FROM stg.airplanes AS s WHERE s._load_id = v_batch AND s.airplane_code = ods_bk.airplane_code
);
IF v_extra_in_ods > 0 THEN
RAISE EXCEPTION 'FAILED: % самолётов в ods.airplanes отсутствуют в STG-батче (%). Возможно, TRUNCATE не выполнился перед INSERT.', v_extra_in_ods, v_batch;
END IF;
RAISE NOTICE 'PASSED: ods.airplanes содержит % самолётов, множество BK = STG-батч %', v_ods_count, v_batch;
END $$;
```
### ODS: `check_ods_seats_rowcount`
**Файл:** `sql/validate/ods_seats_rowcount.sql`
Аналогичная проверка по составному ключу `(airplane_code, seat_no)` —
точное множество BK из STG-батча (по `_load_id` из `ods.seats`).
### ODS: `check_ods_no_dup_bk`
**Файл:** `sql/validate/ods_no_dup_bk.sql`
**Логика:** Проверить, что в ODS нет дублей по бизнес-ключу. Типичная ошибка
студента — INSERT без предшествующего TRUNCATE, или TRUNCATE забыт при повторном
запуске. Дубли по BK в ODS каскадно ломают DDS (лишние строки в измерениях).
```sql
DO $$
DECLARE
v_dup_airplanes BIGINT;
v_dup_seats BIGINT;
BEGIN
-- airplanes: BK = airplane_code
SELECT COUNT(*) INTO v_dup_airplanes
FROM (
SELECT airplane_code FROM ods.airplanes
GROUP BY airplane_code HAVING COUNT(*) > 1
) AS d;
IF v_dup_airplanes > 0 THEN
RAISE EXCEPTION 'FAILED: ods.airplanes содержит % дублирующихся airplane_code. Проверьте, что load начинается с TRUNCATE.', v_dup_airplanes;
END IF;
-- seats: BK = (airplane_code, seat_no)
SELECT COUNT(*) INTO v_dup_seats
FROM (
SELECT airplane_code, seat_no FROM ods.seats
GROUP BY airplane_code, seat_no HAVING COUNT(*) > 1
) AS d;
IF v_dup_seats > 0 THEN
RAISE EXCEPTION 'FAILED: ods.seats содержит % дублирующихся (airplane_code, seat_no). Проверьте, что load начинается с TRUNCATE.', v_dup_seats;
END IF;
RAISE NOTICE 'PASSED: Нет дублей по BK в ods.airplanes и ods.seats';
END $$;
```
### ODS: `check_ods_no_null_pks`
**Файл:** `sql/validate/ods_no_null_pks.sql`
**Логика:** Проверить, что PK-поля не содержат NULL в обеих студенческих ODS-таблицах.
```sql
DO $$
DECLARE
v_null_airplanes BIGINT;
v_null_seats BIGINT;
BEGIN
-- airplanes: PK = airplane_code
SELECT COUNT(*) INTO v_null_airplanes
FROM ods.airplanes WHERE airplane_code IS NULL;
IF v_null_airplanes > 0 THEN
RAISE EXCEPTION 'FAILED: ods.airplanes содержит % строк с NULL в airplane_code.', v_null_airplanes;
END IF;
-- seats: PK = (airplane_code, seat_no)
SELECT COUNT(*) INTO v_null_seats
FROM ods.seats WHERE airplane_code IS NULL OR seat_no IS NULL;
IF v_null_seats > 0 THEN
RAISE EXCEPTION 'FAILED: ods.seats содержит % строк с NULL в PK (airplane_code, seat_no).', v_null_seats;
END IF;
RAISE NOTICE 'PASSED: PK не содержат NULL в ods.airplanes и ods.seats';
END $$;
```
---
### DDS: `check_dim_airplanes_exists`
**Файл:** `sql/validate/dim_airplanes_exists.sql`
**Логика:**
1. Таблица не пуста
2. Нет дублей по `airplane_bk`
3. Покрытие ODS: все `airplane_code` из `ods.airplanes` есть в `dds.dim_airplanes`
```sql
DO $$
DECLARE
v_count BIGINT;
v_dup BIGINT;
v_missing BIGINT;
BEGIN
SELECT COUNT(*) INTO v_count FROM dds.dim_airplanes;
IF v_count = 0 THEN
RAISE EXCEPTION 'FAILED: dds.dim_airplanes пуста. Реализуйте загрузку: sql/dds/dim_airplanes_load.sql';
END IF;
SELECT COUNT(*) - COUNT(DISTINCT airplane_bk) INTO v_dup FROM dds.dim_airplanes;
IF v_dup > 0 THEN
RAISE EXCEPTION 'FAILED: dds.dim_airplanes содержит % дублей по airplane_bk. Проверьте SCD1-логику (UPSERT).', v_dup;
END IF;
SELECT COUNT(*) INTO v_missing
FROM (SELECT DISTINCT airplane_code FROM ods.airplanes) AS s
WHERE NOT EXISTS (
SELECT 1 FROM dds.dim_airplanes AS d WHERE d.airplane_bk = s.airplane_code
);
IF v_missing > 0 THEN
RAISE EXCEPTION 'FAILED: % самолётов из ods.airplanes отсутствуют в dds.dim_airplanes.', v_missing;
END IF;
RAISE NOTICE 'PASSED: dds.dim_airplanes содержит % строк, покрывает все BK из ODS', v_count;
END $$;
```
### DDS: `check_dim_passengers_exists`
**Файл:** `sql/validate/dim_passengers_exists.sql`
Аналогичная структура: не пуста + нет дублей по `passenger_id` + покрытие
`ods.tickets` (все уникальные `passenger_id` из тикетов есть в измерении).
### DDS: `check_dim_passengers_no_dup_bk`
**Файл:** `sql/validate/dim_passengers_no_dup_bk.sql`
Отдельная проверка дублей `passenger_id` — типичная ошибка студентов при SCD1:
INSERT без проверки EXISTS создаёт дубли. Выделена в отдельный таск для ясности
сообщения об ошибке.
**Примечание:** Эту проверку можно объединить с `check_dim_passengers_exists`.
Отдельный таск оправдан, если хотим дать студенту более точную диагностику.
Решение — на усмотрение реализатора.
### DDS: `check_dim_routes_exists`
**Файл:** `sql/validate/dim_routes_exists.sql`
Таблица не пуста + покрытие ODS (`route_no`) + **не более одной текущей версии
на `route_bk`** (инвариант SCD2: `COUNT(*) WHERE valid_to IS NULL` <= 1 для каждого BK).
Типичная ошибка студента — INSERT без проверки `NOT EXISTS ... AND hashdiff = ...`,
что создаёт дубли текущей версии. Активный SCD2-тест проверяет только один маршрут,
эта проверка ловит проблему глобально.
```sql
DO $$
DECLARE
v_count BIGINT;
v_missing BIGINT;
v_multi_current BIGINT;
BEGIN
SELECT COUNT(*) INTO v_count FROM dds.dim_routes;
IF v_count = 0 THEN
RAISE EXCEPTION 'FAILED: dds.dim_routes пуста. Реализуйте загрузку: sql/dds/dim_routes_load.sql';
END IF;
-- Покрытие ODS
SELECT COUNT(*) INTO v_missing
FROM (SELECT DISTINCT route_no FROM ods.routes) AS s
WHERE NOT EXISTS (
SELECT 1 FROM dds.dim_routes AS d WHERE d.route_bk = s.route_no
);
IF v_missing > 0 THEN
RAISE EXCEPTION 'FAILED: % маршрутов из ods.routes отсутствуют в dds.dim_routes.', v_missing;
END IF;
-- Инвариант SCD2: не более одной текущей версии на route_bk
SELECT COUNT(*) INTO v_multi_current
FROM (
SELECT route_bk FROM dds.dim_routes
WHERE valid_to IS NULL
GROUP BY route_bk HAVING COUNT(*) > 1
) AS d;
IF v_multi_current > 0 THEN
RAISE EXCEPTION E'FAILED: % маршрутов имеют более одной текущей версии (valid_to IS NULL).\n'
'Подсказка: при INSERT новой версии проверяйте NOT EXISTS ... AND hashdiff = ...\n'
'чтобы не создавать дубликат, если hashdiff не изменился.', v_multi_current;
END IF;
RAISE NOTICE 'PASSED: dds.dim_routes содержит % строк, покрывает ODS, по одной текущей версии на маршрут', v_count;
END $$;
```
### DDS: `check_dim_routes_scd2` (АКТИВНЫЙ ТЕСТ)
**Файл:** `sql/validate/dim_routes_scd2.sql`
Это ключевая проверка плана. Справочник `bookings.routes` в демо-базе **статичен** —
маршруты не меняются между запусками генератора. При обычном прогоне пайплайна
студент **никогда не увидит**, как SCD2 закрывает старую версию и создаёт новую.
**Алгоритм активного теста:**
```
1. Бэкап: CREATE TABLE _validate_bk_ods_routes AS SELECT * FROM ods.routes;
CREATE TABLE _validate_bk_dim_routes AS SELECT * FROM dds.dim_routes;
(обычные таблицы — TEMP не сохраняются между тасками Airflow)
2. Мутация: Выбрать один маршрут из ods.routes (WHERE departure_time IS NOT NULL LIMIT 1).
Сохранить его route_no в служебную таблицу _validate_scd2_target.
Обновить в ods.routes его departure_time на +1 час.
3. Запуск студенческого кода: PostgresOperator(sql="dds/dim_routes_load.sql")
4. Проверки (все 5):
a) Ровно 2 версии тестового маршрута (было 1, стало 2)
b) Старая версия закрыта: valid_to IS NOT NULL
c) Ровно 1 открытая версия: valid_to IS NULL
d) hashdiff старой ≠ hashdiff новой (мутация отразилась в хеше)
e) Нет «дыры»: старая.valid_to = новая.valid_from
5. Откат (с проверкой существования бэкапов через to_regclass):
IF _validate_bk_ods_routes exists: TRUNCATE ods.routes + INSERT FROM backup;
IF _validate_bk_dim_routes exists: TRUNCATE dds.dim_routes + INSERT FROM backup;
DROP TABLE IF EXISTS _validate_bk_*, _validate_scd2_target;
```
**Реализация:** Этот таск **не может быть простым PostgresOperator** с одним SQL,
потому что шаг 3 — это вызов студенческого SQL-скрипта *внутри* теста. Варианты:
**Выбранный подход: цепочка тасков.**
- Jinja-шаблоны работают из коробки
- Каждый шаг прозрачен в Airflow UI
- `trigger_rule="all_done"` на restore гарантирует откат
- Студент видит в UI, на каком именно шаге проблема
### DDS: `check_dim_routes_no_gaps`
**Файл:** `sql/validate/dim_routes_no_gaps.sql`
**Логика:** Для каждого `route_bk` с несколькими версиями проверить, что
`valid_to` предыдущей версии = `valid_from` следующей (полуоткрытый интервал
`[valid_from, valid_to)` без «дыр»).
**Важно:** SCD2-загрузка поддерживает «исчезнувшие» маршруты (`dim_routes_load.sql`,
Statement 1.1): если маршрут пропал из ODS, его текущая версия закрывается
(`valid_to = CURRENT_DATE`), но новая **не вставляется**. Это корректное поведение —
у такого маршрута `valid_to IS NOT NULL` и `next_valid_from IS NULL`. Проверка
должна считать «дырой» только случаи, когда следующая версия **существует**,
но `valid_to ≠ next_valid_from`.
```sql
DO $$
DECLARE
v_gaps BIGINT;
BEGIN
SELECT COUNT(*) INTO v_gaps
FROM (
SELECT route_bk, valid_to,
LEAD(valid_from) OVER (PARTITION BY route_bk ORDER BY valid_from) AS next_valid_from
FROM dds.dim_routes
) AS t
WHERE valid_to IS NOT NULL
AND next_valid_from IS NOT NULL -- следующая версия существует (не «исчезнувший» маршрут)
AND valid_to <> next_valid_from;
IF v_gaps > 0 THEN
RAISE EXCEPTION 'FAILED: В dds.dim_routes найдено % «дыр» между версиями SCD2. valid_to старой версии должен совпадать с valid_from новой.', v_gaps;
END IF;
RAISE NOTICE 'PASSED: Нет «дыр» в SCD2-версиях dim_routes';
END $$;
```
---
### DM: `check_{vitrine}_exists` (4 таска)
**Файлы:**
- `sql/validate/airport_traffic_exists.sql`
- `sql/validate/route_performance_exists.sql`
- `sql/validate/monthly_overview_exists.sql`
- `sql/validate/passenger_loyalty_exists.sql`
**Логика:** Минимальная проверка — таблица не пуста + нет NULL в ключевых полях.
Пример для `airport_traffic`:
```sql
DO $$
DECLARE
v_count BIGINT;
v_null_pk BIGINT;
BEGIN
SELECT COUNT(*) INTO v_count FROM dm.airport_traffic;
IF v_count = 0 THEN
RAISE EXCEPTION 'FAILED: dm.airport_traffic пуста. Реализуйте загрузку: sql/dm/airport_traffic_load.sql';
END IF;
SELECT COUNT(*) INTO v_null_pk
FROM dm.airport_traffic
WHERE traffic_date IS NULL OR airport_sk IS NULL;
IF v_null_pk > 0 THEN
RAISE EXCEPTION 'FAILED: dm.airport_traffic содержит % строк с NULL в ключе (traffic_date, airport_sk).', v_null_pk;
END IF;
RAISE NOTICE 'PASSED: dm.airport_traffic содержит % строк', v_count;
END $$;
```
Для `route_performance` ключ — `route_bk`, для `monthly_overview` — `(year_actual, month_actual, airplane_sk)`,
для `passenger_loyalty` — `passenger_sk`.
---
## Файлы для создания
### SQL-скрипты (`sql/validate/`)
| # | Файл | Описание |
|---|------|----------|
| 1 | `ods_airplanes_rowcount.sql` | BK coverage: ODS vs STG-батч |
| 2 | `ods_seats_rowcount.sql` | BK coverage: ODS vs STG-батч |
| 3 | `ods_no_dup_bk.sql` | Нет дублей по BK в ODS (airplanes + seats) |
| 4 | `ods_no_null_pks.sql` | NULL в PK обеих ODS-таблиц |
| 5 | `dim_airplanes_exists.sql` | Не пуста + нет дублей BK + покрытие ODS |
| 6 | `dim_passengers_exists.sql` | Не пуста + нет дублей BK + покрытие ODS |
| 7 | `dim_passengers_no_dup_bk.sql` | Дубли `passenger_id` (отдельная диагностика) |
| 8 | `dim_routes_exists.sql` | Не пуста + покрытие ODS + не более 1 текущей версии на BK |
| 9 | `dim_routes_scd2_backup.sql` | Бэкап ods.routes + dds.dim_routes |
| 10 | `dim_routes_scd2_mutate.sql` | Мутация тестового маршрута + сохранение route_no |
| 11 | `dim_routes_scd2_check.sql` | 5 проверок SCD2 после загрузки |
| 12 | `dim_routes_scd2_restore.sql` | Безопасный откат данных из бэкапа |
| 13 | `dim_routes_no_gaps.sql` | Проверка SCD2-интервалов (без «исчезнувших») |
| 14 | `airport_traffic_exists.sql` | Не пуста + NULL в ключе |
| 15 | `route_performance_exists.sql` | Не пуста + NULL в ключе |
| 16 | `monthly_overview_exists.sql` | Не пуста + NULL в ключе |
| 17 | `passenger_loyalty_exists.sql` | Не пуста + NULL в ключе |
### DAG-файл
| # | Файл | Описание |
|---|------|----------|
| 18 | `airflow/dags/bookings_validate.py` | DAG с TaskGroup по слоям |
### Тест
| # | Файл | Описание |
|---|------|----------|
| 19 | Дополнение `tests/test_dags_smoke.py` | Smoke-тест структуры DAG |
---
## DAG-файл: `bookings_validate.py`
```python
"""
Валидационный DAG: самопроверка студенческих заданий.
Запускается вручную в Airflow UI после реализации заданий.
Таски сгруппированы по слоям — студент видит, где именно проблема.
"""
from datetime import timedelta
from logging import getLogger
import pendulum
from airflow import DAG
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow.utils.task_group import TaskGroup
GREENPLUM_CONN_ID = "greenplum_conn"
log = getLogger(__name__)
default_args = {
"owner": "airflow",
"retries": 0, # Без ретраев — студент должен увидеть ошибку сразу
"retry_delay": timedelta(seconds=10),
}
with DAG(
dag_id="bookings_validate",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None, # Только ручной запуск
catchup=False,
max_active_runs=1,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "bookings", "greenplum", "validate"],
description="Валидация студенческих заданий: ODS, DDS, DM",
) as dag:
with TaskGroup("validate_ods") as validate_ods:
check_ods_airplanes = PostgresOperator(
task_id="check_ods_airplanes_rowcount",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_airplanes_rowcount.sql",
)
check_ods_seats = PostgresOperator(
task_id="check_ods_seats_rowcount",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_seats_rowcount.sql",
)
check_ods_dup_bk = PostgresOperator(
task_id="check_ods_no_dup_bk",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_no_dup_bk.sql",
)
check_ods_pks = PostgresOperator(
task_id="check_ods_no_null_pks",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_no_null_pks.sql",
)
with TaskGroup("validate_dds") as validate_dds:
check_dim_airplanes = PostgresOperator(
task_id="check_dim_airplanes_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_airplanes_exists.sql",
)
check_dim_passengers = PostgresOperator(
task_id="check_dim_passengers_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_passengers_exists.sql",
)
check_dim_passengers_dup = PostgresOperator(
task_id="check_dim_passengers_no_dup_bk",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_passengers_no_dup_bk.sql",
)
check_dim_routes = PostgresOperator(
task_id="check_dim_routes_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_exists.sql",
)
# SCD2 активный тест: цепочка backup → mutate → load → check → restore
scd2_backup = PostgresOperator(
task_id="scd2_backup",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_backup.sql",
)
scd2_mutate = PostgresOperator(
task_id="scd2_mutate",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_mutate.sql",
)
scd2_run_load = PostgresOperator(
task_id="scd2_run_student_load",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_routes_load.sql", # студенческий load-скрипт!
)
scd2_check = PostgresOperator(
task_id="scd2_check",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_check.sql",
)
scd2_restore = PostgresOperator(
task_id="scd2_restore",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_restore.sql",
trigger_rule="all_done", # Откат ВСЕГДА, даже если check упал
)
check_dim_routes_gaps = PostgresOperator(
task_id="check_dim_routes_no_gaps",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_no_gaps.sql",
)
# SCD2 цепочка
scd2_backup >> scd2_mutate >> scd2_run_load >> scd2_check >> scd2_restore
# no_gaps запускается после restore (на чистых данных)
scd2_restore >> check_dim_routes_gaps
with TaskGroup("validate_dm") as validate_dm:
check_airport_traffic = PostgresOperator(
task_id="check_airport_traffic_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/airport_traffic_exists.sql",
)
check_route_performance = PostgresOperator(
task_id="check_route_performance_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/route_performance_exists.sql",
)
check_monthly_overview = PostgresOperator(
task_id="check_monthly_overview_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/monthly_overview_exists.sql",
)
check_passenger_loyalty = PostgresOperator(
task_id="check_passenger_loyalty_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/passenger_loyalty_exists.sql",
)
```
---
## Активный тест SCD2: детализация SQL
### `dim_routes_scd2_backup.sql`
```sql
-- Бэкап текущего состояния перед тестом SCD2.
-- Используем обычные таблицы (не TEMP) — между тасками Airflow
-- TEMP-таблицы не сохраняются (каждый таск = отдельная транзакция).
DROP TABLE IF EXISTS _validate_bk_ods_routes;
CREATE TABLE _validate_bk_ods_routes AS SELECT * FROM ods.routes;
DROP TABLE IF EXISTS _validate_bk_dim_routes;
CREATE TABLE _validate_bk_dim_routes AS SELECT * FROM dds.dim_routes;
-- Cleanup служебной таблицы от предыдущего запуска (на случай если restore не доехал)
DROP TABLE IF EXISTS _validate_scd2_target;
```
### `dim_routes_scd2_mutate.sql`
```sql
-- Мутация: сдвигаем departure_time у одного маршрута на 1 час.
-- Это должно изменить hashdiff → SCD2 должен закрыть старую версию.
--
-- Сохраняем route_no тестового маршрута в служебную таблицу _validate_scd2_target,
-- чтобы check-скрипт точно знал, какой маршрут проверять (а не угадывал по побочным эффектам).
DO $$
DECLARE
v_route TEXT;
v_old_time TIME;
BEGIN
-- Берём первый маршрут, у которого departure_time заполнен
SELECT route_no, departure_time
INTO v_route, v_old_time
FROM ods.routes
WHERE departure_time IS NOT NULL
ORDER BY route_no
LIMIT 1;
IF v_route IS NULL THEN
RAISE EXCEPTION 'FAILED: ods.routes пуста или нет маршрутов с departure_time. Загрузите STG→ODS перед проверкой.';
END IF;
-- Запоминаем тестовый маршрут в служебную таблицу
DROP TABLE IF EXISTS _validate_scd2_target;
CREATE TABLE _validate_scd2_target AS
SELECT v_route AS route_no;
-- Сдвигаем время на 1 час у всех записей этого маршрута
UPDATE ods.routes
SET departure_time = departure_time + INTERVAL '1 hour'
WHERE route_no = v_route;
RAISE NOTICE 'SCD2 TEST: маршрут % — departure_time сдвинут с % на %',
v_route, v_old_time, v_old_time + INTERVAL '1 hour';
END $$;
```
### `dim_routes_scd2_check.sql`
```sql
-- Проверяем, что SCD2-логика студента сработала корректно.
-- Читаем route_no тестового маршрута из служебной таблицы _validate_scd2_target
-- (создана на шаге mutate), а не угадываем по побочным эффектам.
DO $$
DECLARE
v_route TEXT;
v_version_count BIGINT;
v_closed_count BIGINT;
v_open_count BIGINT;
v_old_hash TEXT;
v_new_hash TEXT;
v_gap_count BIGINT;
BEGIN
-- Читаем тестовый маршрут из служебной таблицы
SELECT route_no INTO v_route FROM _validate_scd2_target LIMIT 1;
IF v_route IS NULL THEN
RAISE EXCEPTION 'FAILED: служебная таблица _validate_scd2_target пуста. Шаг mutate не выполнился?';
END IF;
-- Если dim_routes пуста — load не запустился
IF NOT EXISTS (SELECT 1 FROM dds.dim_routes WHERE route_bk = v_route) THEN
RAISE EXCEPTION E'FAILED: dds.dim_routes не содержит маршрут % после запуска load.\n'
'Проверьте sql/dds/dim_routes_load.sql.', v_route;
END IF;
-- Проверка a: Ровно 2 версии тестового маршрута (было 1, стало 2 после мутации)
SELECT COUNT(*) INTO v_version_count
FROM dds.dim_routes WHERE route_bk = v_route;
IF v_version_count < 2 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — найдена % версия (ожидается 2: старая закрытая + новая открытая).\n'
'SCD2 должен был создать новую версию после изменения departure_time.', v_route, v_version_count;
END IF;
IF v_version_count > 2 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — найдено % версий (ожидается 2).\n'
'Возможно, load создаёт лишние дубликаты. Проверьте условие NOT EXISTS при INSERT.', v_route, v_version_count;
END IF;
-- Проверка b: Старая версия закрыта (valid_to IS NOT NULL)
SELECT COUNT(*) INTO v_closed_count
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NOT NULL;
IF v_closed_count = 0 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — SCD2 не закрыл старую версию (valid_to IS NULL у всех версий).\n'
'Подсказка: hashdiff изменился (departure_time сдвинут на 1 час),\n'
'но ваш load-скрипт не обнаружил это изменение.\n'
'Проверьте:\n'
' 1. Формулу hashdiff — включает ли она departure_time?\n'
' 2. Логику сравнения hashdiff (UPDATE ... SET valid_to = CURRENT_DATE WHERE hashdiff <> новый_hashdiff)', v_route;
END IF;
-- Проверка c: Новая версия открыта (valid_to IS NULL)
SELECT COUNT(*) INTO v_open_count
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NULL;
IF v_open_count <> 1 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — ожидается ровно 1 открытая версия (valid_to IS NULL), найдено %.\n'
'Подсказка: SCD2 должен вставить новую строку с valid_to = NULL.', v_route, v_open_count;
END IF;
-- Проверка d: hashdiff старой ≠ hashdiff новой (мутация действительно отразилась)
SELECT hashdiff INTO v_old_hash
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NOT NULL
ORDER BY valid_from DESC LIMIT 1;
SELECT hashdiff INTO v_new_hash
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NULL;
IF v_old_hash = v_new_hash THEN
RAISE EXCEPTION E'FAILED: Маршрут % — hashdiff старой и новой версий совпадают.\n'
'Мутация сдвинула departure_time на 1 час, но hashdiff не изменился.\n'
'Проверьте, что departure_time входит в формулу hashdiff.', v_route;
END IF;
-- Проверка e: Нет «дыры» между valid_to старой и valid_from новой
SELECT COUNT(*) INTO v_gap_count
FROM dds.dim_routes AS old_v
JOIN dds.dim_routes AS new_v
ON old_v.route_bk = new_v.route_bk
WHERE old_v.route_bk = v_route
AND old_v.valid_to IS NOT NULL
AND new_v.valid_to IS NULL
AND old_v.valid_to <> new_v.valid_from;
IF v_gap_count > 0 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — «дыра» между версиями:\n'
'valid_to старой ≠ valid_from новой.\n'
'Подсказка: полуоткрытый интервал [valid_from, valid_to).\n'
'valid_from новой версии должен = valid_to старой (обычно CURRENT_DATE).', v_route;
END IF;
RAISE NOTICE 'PASSED: SCD2 корректен для маршрута %. 2 версии, hashdiff различаются, «дыр» нет.', v_route;
END $$;
```
### `dim_routes_scd2_restore.sql`
```sql
-- Откат данных после теста SCD2.
-- Выполняется ВСЕГДА (trigger_rule="all_done"), даже если check упал.
--
-- Безопасность: если backup-шаг не создал таблицы (сбой на backup),
-- откат НЕ трогает live-данные — просто чистит служебные таблицы.
-- Это гарантирует, что restore никогда не сломает ods.routes / dds.dim_routes.
DO $$
DECLARE
v_has_ods_backup BOOLEAN;
v_has_dim_backup BOOLEAN;
BEGIN
-- Проверяем существование backup-таблиц через to_regclass
-- (ищет по search_path — совпадает с тем, как CREATE TABLE их создал)
v_has_ods_backup := to_regclass('_validate_bk_ods_routes') IS NOT NULL;
v_has_dim_backup := to_regclass('_validate_bk_dim_routes') IS NOT NULL;
-- Восстанавливаем ods.routes только если бэкап существует
IF v_has_ods_backup THEN
TRUNCATE ods.routes;
INSERT INTO ods.routes SELECT * FROM _validate_bk_ods_routes;
RAISE NOTICE 'RESTORE: ods.routes восстановлена из бэкапа';
ELSE
RAISE NOTICE 'RESTORE: бэкап ods.routes не найден — пропускаем (backup-шаг не завершился?)';
END IF;
-- Восстанавливаем dds.dim_routes только если бэкап существует
IF v_has_dim_backup THEN
TRUNCATE dds.dim_routes;
INSERT INTO dds.dim_routes SELECT * FROM _validate_bk_dim_routes;
RAISE NOTICE 'RESTORE: dds.dim_routes восстановлена из бэкапа';
ELSE
RAISE NOTICE 'RESTORE: бэкап dds.dim_routes не найден — пропускаем';
END IF;
END $$;
-- Cleanup служебных таблиц (безусловно, IF EXISTS)
DROP TABLE IF EXISTS _validate_bk_ods_routes;
DROP TABLE IF EXISTS _validate_bk_dim_routes;
DROP TABLE IF EXISTS _validate_scd2_target;
-- Учебный комментарий: Мы восстанавливаем данные из бэкапа, чтобы тест
-- не оставлял «мусорных» версий в dim_routes. Это стандартный паттерн
-- для интеграционных тестов: setup → act → assert → teardown.
-- IF EXISTS проверки гарантируют, что restore безопасен при любом сценарии сбоя.
```
---
## Smoke-тест DAG (дополнение `test_dags_smoke.py`)
Добавить новый тест-класс:
```python
class TestBookingsValidate:
"""Smoke-тесты DAG bookings_validate."""
def test_dag_loads(self):
dag = _load_dag("airflow.dags.bookings_validate")
assert dag is not None
def test_expected_tasks(self):
dag = _load_dag("airflow.dags.bookings_validate")
expected = {
# ODS
"validate_ods.check_ods_airplanes_rowcount",
"validate_ods.check_ods_seats_rowcount",
"validate_ods.check_ods_no_dup_bk",
"validate_ods.check_ods_no_null_pks",
# DDS
"validate_dds.check_dim_airplanes_exists",
"validate_dds.check_dim_passengers_exists",
"validate_dds.check_dim_passengers_no_dup_bk",
"validate_dds.check_dim_routes_exists",
"validate_dds.scd2_backup",
"validate_dds.scd2_mutate",
"validate_dds.scd2_run_student_load",
"validate_dds.scd2_check",
"validate_dds.scd2_restore",
"validate_dds.check_dim_routes_no_gaps",
# DM
"validate_dm.check_airport_traffic_exists",
"validate_dm.check_route_performance_exists",
"validate_dm.check_monthly_overview_exists",
"validate_dm.check_passenger_loyalty_exists",
}
assert expected.issubset(dag.task_dict.keys())
def test_scd2_chain(self):
"""SCD2 цепочка backup → mutate → load → check → restore."""
dag = _load_dag("airflow.dags.bookings_validate")
_assert_direct_edge(dag, "validate_dds.scd2_backup", "validate_dds.scd2_mutate")
_assert_direct_edge(dag, "validate_dds.scd2_mutate", "validate_dds.scd2_run_student_load")
_assert_direct_edge(dag, "validate_dds.scd2_run_student_load", "validate_dds.scd2_check")
_assert_direct_edge(dag, "validate_dds.scd2_check", "validate_dds.scd2_restore")
def test_no_gaps_after_restore(self):
"""no_gaps должен выполняться после restore (на чистых данных)."""
dag = _load_dag("airflow.dags.bookings_validate")
_assert_direct_edge(dag, "validate_dds.scd2_restore", "validate_dds.check_dim_routes_no_gaps")
def test_restore_trigger_rule(self):
"""restore должен выполняться всегда (all_done), даже если check упал."""
dag = _load_dag("airflow.dags.bookings_validate")
restore_task = dag.task_dict["validate_dds.scd2_restore"]
assert restore_task.trigger_rule == "all_done"
```
---
## Порядок реализации
1. Создать каталог `sql/validate/`
2. Написать SQL-скрипты (17 файлов) — начать с простых (ODS, DM), затем DDS, затем SCD2
3. Написать DAG `bookings_validate.py`
4. Дополнить `tests/test_dags_smoke.py`
5. `make test` — smoke-тесты проходят
6. Ручная проверка на стенде (если поднят):
- Trigger DAG в Airflow UI
- Все ODS/DDS/DM зелёные (на solution-ветке)
- SCD2 активный тест: backup → mutate → load → check (зелёный) → restore
7. Коммит
---
## Верификация
### Автоматическая
```bash
make test # smoke-тест DAG-структуры
```
### Ручная (на стенде)
1. `make up && make ddl-gp` → запустить STG → ODS → DDS → DM
2. Trigger `bookings_validate` в Airflow UI
3. Проверить: все таски зелёные
4. Проверить SCD2: в логах `scd2_check` видно `PASSED: SCD2 корректен для маршрута ...`
5. Проверить restore: `ods.routes` и `dds.dim_routes` не изменились после теста
### Сценарий «студент ещё не реализовал»
На main-ветке (с DDL-заглушками):
- ODS-таски: FAILED (таблицы пусты) → дружелюбное сообщение
- DDS-таски: FAILED → сообщение «Реализуйте загрузку»
- DM-таски: FAILED → сообщение «Реализуйте загрузку»
- SCD2-тест: `scd2_run_student_load` пройдёт (заглушка `SELECT 1;` — валидный SQL),
но `scd2_check` упадёт (dim_routes не обновилась, версий < 2)
→ `scd2_restore` всё равно выполнится (`trigger_rule="all_done"`)
---
## Открытые вопросы
1. **`dim_passengers_no_dup_bk` — отдельный таск или объединить с `exists`?**
Отдельный таск даёт точнее диагностику, но увеличивает число тасков.
Рекомендация: объединить проверки в один файл `dim_passengers_exists.sql`
(как сделано для `dim_airplanes_exists`).
2. **Нужна ли проверка `total_seats` в `dim_airplanes`?**
`total_seats` вычисляется агрегацией из `ods.seats`. Если студент забудет
этот JOIN — поле будет NULL. Можно добавить `SELECT COUNT(*) WHERE total_seats IS NULL`.
Рекомендация: добавить в `dim_airplanes_exists.sql`.
3. **Бэкап SCD2: обычные таблицы vs TEMP?**
Каждый таск Airflow — отдельная транзакция → TEMP-таблицы не сохраняются.
Используем обычные таблицы с префиксом `_validate_bk_`. Риск: если DAG упадёт
между backup и restore, таблицы останутся. Cleanup: `scd2_restore` делает
`DROP TABLE IF EXISTS`, поэтому при следующем запуске проблем не будет.
---
## Ключевые файлы
| Файл | Роль |
|------|------|
| `airflow/dags/bookings_validate.py` | DAG: TaskGroup по слоям |
| `sql/validate/*.sql` | 17 SQL-скриптов проверок |
| `sql/dds/dim_routes_load.sql` | Студенческий load (вызывается из SCD2-теста) |
| `tests/test_dags_smoke.py` | Smoke-тесты DAG-структуры |
| `docs/design/assignment_design.md` | Дизайн (секция 4 — источник требований) |
@@ -0,0 +1,34 @@
# План: Онбординг студента + маркетинг (v4)
> Статус: выполнен (2026-03-13)
## Контекст
Ментор опционален. Студент, клонировав main, проходит «Быстрый старт» — и застревает:
нет явного «что дальше», ссылка на задания спрятана, validate DAG не объяснён, нет маркетинга.
На main есть противоречия: docs и DAG-docstrings говорят «все реализовано»,
фактически стоят заглушки `SELECT 1;`.
## Что было сделано
### На main (10 файлов)
1. **README.md** — маркетинг-баннер, шаг 6 (только эталонные таблицы), шаг 7 → задания.
2. **docs/assignment/README.md** — полный гид студента (эталон → ТЗ → заглушки → validate).
3. **docs/assignment/analyst_spec.md** — DAG-интеграция: «таски уже подключены, DAG менять не нужно».
4. **docs/bookings_to_gp_dm.md** — пометки заглушек, адаптация секций проверки.
5. **docs/bookings_to_gp_ods.md** — пометки заглушек airplanes/seats.
6. **docs/bookings_to_gp_dds.md** — пометки заглушек dim_routes/dim_passengers/dim_airplanes.
7-10. **4 DAG docstrings** — эталон vs задания (заглушки).
### На solution (follow-up)
- README.md — маркетинг-баннер + шаг 7 (solution-specific: «Изучите готовую реализацию»).
- docs/assignment/README.md — навигационный гид по всем паттернам (ODS → DDS → DM).
- Этот план архивирован в docs/archive/.
## Верификация
- `make test` — passed на обеих ветках.
- `make lint` — clean на обеих ветках.
- Grep «заглушки/SELECT 1» в README.md и docs/assignment/README.md на solution — не найдено.
+304
View File
@@ -0,0 +1,304 @@
# Ревью архитектуры слоёв DWH: оценка учебной ценности
> Дата: 2026-03-01 (ревью), 2026-03-11 (закрытие)
> Статус: **завершён** — P0/P1/P2 выполнены, P3 отложены (покрыты другими документами)
> Контекст: оценка текущей конструкции слоёв с точки зрения учебных целей
## Context
Стенд — курсовая работа и эталон для менти-джунов. Они понесут эти паттерны на свою первую работу. Оцениваем по двум осям: **production-ready** (чтобы не стыдно было показать на собеседовании) и **KISS** (чтобы джун не утонул в сложности).
Текущее состояние: 95 SQL-файлов, 5 слоёв (STG→ODS→DDS→DM), 9 DAG-ов, полная Star Schema с SCD2, DQ на каждом шаге. Реализована 1 из 5 витрин DM.
---
## СИЛЬНЫЕ СТОРОНЫ (что уже отлично)
### 1. Паттерн load → DQ на каждом шаге — эталонный
Каждая сущность в каждом слое имеет тройку файлов `_ddl.sql` / `_load.sql` / `_dq.sql`. DAG-и обеспечивают порядок load→dq→next. Smoke-тесты проверяют рёбра графа. Студенты усвоят: **DQ — не опция, а часть пайплайна**.
### 2. UPSERT через UPDATE + INSERT — production-grade для Greenplum
Не DELETE+INSERT (дорого на AO-таблицах), не MERGE (нет в GP6). `IS DISTINCT FROM` для null-safe сравнения — деталь, которую даже опытные инженеры забывают.
### 3. Антипаттерн-обучение в DM (distribution by date)
Комментарий в `dm/sales_report_ddl.sql` объясняет **почему** нельзя распределять по дате, с конкретными причинами (Load Skew, Processing Skew). Это «почему нет» — именно то, что не дают учебники.
### 4. SCD2 в dim_routes — полный и корректный
Все три кейса: закрытие изменённых версий (hashdiff), закрытие исчезнувших маршрутов, вставка новых версий с правильной логикой valid_from. DQ проверяет пересечение интервалов. Готовый reference implementation.
### 5. Point-in-time lookup в факте — ключевой навык
```sql
LEFT JOIN dds.dim_routes AS rte
ON rte.route_bk = flt.route_no
AND flt.scheduled_departure::DATE >= rte.valid_from
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
```
Многие продакшн-DWH ошибаются, присоединяя только текущую версию.
### 6. HWM-инкрементальность в DM — самовосстанавливающийся пайплайн
`MAX(_load_ts)` + TEMP TABLE для однократной агрегации — канон MPP. Пайплайн сам «догоняет» пропущенные дни.
### 7. DAG-графы корректно отражают зависимости данных
Параллельность airports/airplanes, gates на routes (нужны оба), факт после всех измерений. Smoke-тесты проверяют и наличие, и **отсутствие** рёбер (параллельность).
### 8. ANALYZE после каждой загрузки
GP-специфичная best practice, которую забывают даже опытные команды.
### 9. Идемпотентные STG-загрузки
`NOT EXISTS (... WHERE _load_id = '{{ run_id }}')` — простой, корректный, понятный паттерн для retry-safe загрузок.
---
## ЗАМЕЧАНИЯ И ЗАДАЧИ ДЛЯ ДОРАБОТКИ
### P0: Фактическая ошибка (исправить до показа студентам)
- [x] **ODS batch resolver теряет данные при двух STG-запусках подряд**
- Сценарий: STG run_1 загружает день N, STG run_2 загружает день N+1, затем ODS запускается
- `_resolve_stg_batch_id()` выбирает только последний согласованный batch (`run_2`)
- Все ODS load-скрипты фильтруют `WHERE _load_id = 'run_2'` → данные `run_1` навсегда пропущены
- **Справочники** (airports, airplanes, routes, seats): проблемы нет — full snapshot, `run_2` содержит всё
- **Транзакционные таблицы** (bookings, tickets, flights, segments, boarding_passes): **потеря данных** — инкрементальные записи `run_1` никогда не попадут в ODS
- Корень проблемы: batch resolver проектировался для согласованности справочников (INTERSECT), но тот же single-batch фильтр применяется к транзакционным таблицам, где нужны **все необработанные** batch-и
- **Нужно**: разделить логику — для транзакционных таблиц загружать все batch-и с `_load_ts > MAX(_load_ts в ODS)` (аналог HWM из DM), для справочников — по-прежнему последний согласованный
- Файлы: `airflow/dags/bookings_to_gp_ods.py`, `sql/ods/bookings_load.sql`, `sql/ods/tickets_load.sql`, `sql/ods/flights_load.sql`, `sql/ods/segments_load.sql`, `sql/ods/boarding_passes_load.sql`
- [x] **Противоречие в distribution key для airport_traffic**
- `bookings_dm_design.md` (строка 182): `DISTRIBUTED BY (traffic_date)`
- `sales_report_ddl.sql`: явно объясняет, почему distribution by date — антипаттерн
- **Нужно**: исправить на `DISTRIBUTED BY (airport_sk)` в дизайн-документе
- Файл: `docs/design/bookings_dm_design.md`
### P1: Высокий эффект, минимум усилий (комментарии и документация)
- [x] **Нет объяснения «почему не SERIAL» в генерации SK**
- `MAX(sk) + ROW_NUMBER()` корректен для GP, но студент на PostgreSQL/Snowflake будет использовать `IDENTITY`/`SEQUENCE`
- **Нужно**: 4-строчный комментарий в `sql/dds/dim_airports_load.sql`
- [x] **Факт без суррогатного ключа — не объяснено «почему»**
- Натуральный (ticket_no, flight_id) как grain — правильное Kimball-моделирование
- **Нужно**: комментарий в `sql/dds/fact_flight_sales_ddl.sql`
- [x] **Нет упоминания cross-DAG зависимостей**
- STG, ODS, DDS, DM — отдельные DAG-и с `schedule=None`, студент может не понять порядок
- **Нужно**: комментарий в docstring каждого DAG или `docs/dag_execution_order.md`
- [x] **Late-arriving dimensions не упомянуты**
- Факт делает LEFT JOIN → `passenger_sk = NULL` при опоздании; нет механизма исправления
- **Нужно**: комментарий в `sql/dds/fact_flight_sales_load.sql` у LEFT JOIN-ов
- [x] **Batch resolver недообъяснён**
- `_resolve_stg_batch_id` с INTERSECT по 4 таблицам — нет комментария **зачем** нужна согласованность
- **Нужно**: комментарий в `airflow/dags/bookings_to_gp_ods.py` перед SQL-запросом
- [x] **`helpers/greenplum.py`** — удалён вместе с CSV-пайплайном (перенесён в airflow-manual)
### P2: Средние усилия, заметное улучшение качества
- [x] **Явный storage type для всех таблиц + AO где возможно** ✅ РЕШЕНИЕ ПРИНЯТО
- 18 из 28 таблиц имели неявный heap (нет `WITH`) — теперь выбор сделан явно
- **Целевая раскладка по storage:**
- **AO Row + zstd**: `dds.dim_calendar` (узкая таблица, column-store не даёт выигрыша)
- **AO Row + zstd**: ODS snapshot-справочники (`airports`, `airplanes`, `routes`, `seats`)
— перевести загрузку с UPSERT на TRUNCATE+INSERT (честнее для full snapshot семантики)
- **AO Row + zstd**: `dds.dim_tariffs` (только INSERT, нет UPDATE)
- **AO Row + zstd**: `dm.route_performance` (full rebuild, по дизайну)
- **Heap (явный)**: ODS транзакционные (`bookings`, `tickets`, `flights`, `segments`,
`boarding_passes`) — row-level UPDATE при SCD1 UPSERT
- **Heap (явный)**: DDS измерения с UPDATE (`dim_airports`, `dim_airplanes`,
`dim_passengers`, `dim_routes`) и `fact_flight_sales`
- **Heap (явный)**: DM витрины с UPSERT (`sales_report` и будущие HWM-витрины)
- К каждой таблице добавить комментарий, объясняющий выбор storage type
- Файлы: все `*_ddl.sql` в ods/, dds/, dm/ + переписать 4 ODS snapshot load-скрипта
- См. ADR-3
- [x] **Дублирование hashdiff CTE в dim_routes_load.sql**
- md5(COALESCE(...)) повторяется в Statement 1 и Statement 2, ROW_NUMBER() — 3 раза
- **Решение**: вынесено в CREATE TEMP TABLE tmp_routes_src ON COMMIT DROP ✅ ВЫПОЛНЕНО
- Файл: `sql/dds/dim_routes_load.sql`
- [x] **Несогласованность нейминга STG vs ODS+** ✅ ВЫПОЛНЕНО
- STG: `batch_id`, `load_dttm`, `src_created_at_ts` → переименованы в канон `_load_id`, `_load_ts`, `event_ts`
- Единый словарь во всех слоях снижает когнитивную нагрузку
- Секция 6 «Переходный маппинг» удалена из `naming_conventions.md` как неактуальная
- Файлы: 27 STG SQL + ODS load-скрипты + `naming_conventions.md` + тесты
- [x] **Дублирование CTE в ODS load-скриптах**
- `WITH src AS (...)` копируется 2-3 раза в каждом из 9 ODS load-файлов
- **Решение**: TEMP TABLE для самых сложных (airports, flights, routes); простые — оставить
- Файлы: `sql/ods/airports_load.sql`, `sql/ods/flights_load.sql`, `sql/ods/routes_load.sql`
- *Заметка*: Для всех транзакционных таблиц ODS внедрен паттерн TEMP TABLE для надежной работы HWM.
- [x] **DM слой спроектирован**
- 5 витрин: `sales_report`, `route_performance`, `passenger_loyalty`, `airport_traffic`, `monthly_overview`
- `sales_report`, `route_performance` — эталонные реализации; `passenger_loyalty`, `airport_traffic`, `monthly_overview` — задания для студентов
- `route_performance` — full rebuild + AO Column Store
### P3: Отложено (покрыто другими документами)
- [~] **Крутая лестница сложности ODS→DDS**
- Покрыто: `docs/assignment/analyst_spec.md` содержит рекомендуемый порядок выполнения
(STG → ODS → DDS SCD1 → DDS SCD2 → DM от простого к сложному)
- [~] **DQ без переиспользуемых функций**
- Отложено: переусложнение для учебного стенда (AGENTS.md: KISS)
- [~] **Нет документа по стратегии distribution**
- Покрыто: подсказки по DK есть в `analyst_spec.md` и комментариях к каждому DDL
- [~] **Отсутствующие паттерны** (комментарии/заметки)
- Покрыто частично: partitioning и exchange partition описаны в ADR-2 (этот документ).
SCD3/6 и data lineage — вне скоупа курсовой
---
## ПРИНЯТЫЕ АРХИТЕКТУРНЫЕ РЕШЕНИЯ
### ADR-1: Города, страны, модели самолётов — атрибуты измерений, не отдельные справочники
**Рассматривалось**: выделить `dim_city`, `dim_country`, `dim_airplane_model` как отдельные
измерения со своими суррогатными ключами.
**Решение**: оставить `city`, `country` как атрибуты `dim_airports`, а `model` — как атрибут
`dim_airplanes`. Не создавать отдельные справочники.
**Обоснование**:
1. **Star vs Snowflake.** Kimball-методология рекомендует «wide and flat» измерения.
Вынос атрибутов в подтаблицы превращает star schema в snowflake — добавляет 2-3 JOIN-а
в каждый запрос к факту без аналитического выигрыша. Для учебного стенда star schema —
правильный эталон.
2. **Нет самостоятельной сущности в домене.** Город — JSON-атрибут аэропорта в источнике
(`airport_name::json->>'ru'`). У него нет своего бизнес-ключа, жизненного цикла,
независимых атрибутов. Модель самолёта — аналогично.
3. **Когнитивная нагрузка.** Лестница ODS→DDS уже крутая (6 измерений + 1 факт + SCD2).
Добавление 2-3 измерений усложнит стенд без пропорционального обучающего эффекта.
**Когда отдельное измерение оправдано** (для справки студентам):
- Город имеет собственные атрибуты из другого источника (население, регион, координаты)
→ `dim_geography` как outrigger-измерение
- Модель самолёта имеет независимые характеристики (производитель, сертификация, конфигурации)
→ `dim_aircraft_type`
- В Data Vault — `hub_city` / `hub_country` как самостоятельные бизнес-объекты (другая парадигма)
### ADR-2: Heap + UPSERT вместо AO + партиционирование + exchange partition
**Контекст**: Greenplum широко распространён в РФ — на него активно мигрировали при
импортозамещении с Teradata и Exadata. Именно поэтому GP выбран для курсовой: опыт работы
с ним будет напрямую релевантен первой работе студента. Тем важнее, чтобы студенты понимали,
как устроены реальные GP-хранилища, даже если стенд использует упрощённый подход.
**Рассматривалось**: использовать production-паттерн крупных GP-хранилищ:
- AO Column Store (сжатие zlib/zstd, векторное чтение, колоночное хранение)
- Range-партиционирование по дате (`PARTITION BY RANGE (flight_date)`)
- Обновление через замену партиций (`ALTER TABLE EXCHANGE PARTITION`) или
`DELETE + INSERT` в рамках одной партиции вместо row-level UPDATE
**Решение**: партиционирование не применяем (учебные объёмы). Для storage —
дифференцированный подход: heap для таблиц с UPDATE, AO для иммутабельных
(см. ADR-3 с полной раскладкой).
**Обоснование**:
1. **Универсальность паттерна.** UPSERT через UPDATE + INSERT работает в PostgreSQL,
Snowflake, BigQuery, Redshift — везде. Exchange partition — GP-специфика
(`ALTER TABLE ... EXCHANGE PARTITION FOR (...) WITH TABLE tmp_...`).
Студент, освоив UPSERT, сможет применить его на любой платформе.
2. **Объём данных.** На учебных ~100K строк партиционирование не даёт partition pruning
эффекта, зато утраивает DDL (стратегия, sub-partitions, retention policy).
Выигрыш нулевой, когнитивная нагрузка — существенная.
3. **Простота ментальной модели.** «Вот строка, она обновилась» понятнее, чем «вот партиция,
она заменилась целиком». Второй паттерн требует понимания storage engine, что выходит
за рамки первого курса DWH.
**Что студенту важно знать про реальный GP** (для менти):
На продакшн-хранилищах с десятками и сотнями миллионов строк подход меняется принципиально:
| Аспект | Стенд (учебный) | Продакшн (реальный GP) |
|--------|-----------------|----------------------|
| Хранение фактов | Heap (row-oriented) | AO Column Store (сжатие, колонки) |
| Партиционирование | Нет | Range по дате (день/месяц) |
| Обновление | Row-level UPDATE | Exchange partition или DELETE+INSERT в партиции |
| Причина | UPDATE на AO «раздувает» таблицу (помечает строки deleted, дописывает новые) | |
| Когда переходить | > 10M строк, или когда VACUUM не справляется | |
Типичный production-паттерн загрузки факта по дням:
```sql
-- 1. Собрать новую партицию во временную таблицу
CREATE TABLE tmp_fact_20170102 (LIKE dds.fact_flight_sales)
WITH (appendonly=true, orientation=column, compresstype=zstd);
INSERT INTO tmp_fact_20170102 SELECT ... FROM ods... WHERE flight_date = '2017-01-02';
-- 2. Атомарно заменить партицию (без DELETE, без UPDATE)
ALTER TABLE dds.fact_flight_sales
EXCHANGE PARTITION FOR ('2017-01-02') WITH TABLE tmp_fact_20170102;
-- 3. Удалить временную таблицу (теперь в ней старые данные)
DROP TABLE tmp_fact_20170102;
```
Преимущества exchange partition:
- Нет row-level UPDATE → нет bloat, не нужен VACUUM
- AO Column Store даёт 5-10x сжатие и быстрые аналитические скана
- Partition pruning: запрос `WHERE flight_date = '2017-01-02'` читает только одну партицию
- Атомарность: EXCHANGE — одна DDL-команда, нет окна неконсистентности
### ADR-3: Явный storage type для каждой таблицы + AO где нет UPDATE
**Проблема**: 18 из 28 таблиц в ODS/DDS/DM создаются без `WITH`-клаузы. GP по умолчанию
создаёт heap, но студент не видит осознанного выбора — таблица «просто создаётся».
В учебном стенде каждое решение должно быть видимым и объяснённым.
**Решение**: добавить явный `WITH (...)` ко всем таблицам. Где row-level UPDATE не нужен —
перевести на AO (Row или Column) с компрессией.
**Целевая раскладка storage по таблицам:**
| Storage | Таблицы | Почему |
|---------|---------|--------|
| **AO Column** zstd | `dds.dim_calendar` | Write-once (generate_series), никогда не обновляется. Колоночное хранение идеально для аналитических скан. |
| **AO Column** zstd | `dm.route_performance` | Full rebuild (TRUNCATE+INSERT), чисто аналитические чтения. |
| **AO Row** zstd | STG: все 9 таблиц | Уже реализовано. Append-only, иммутабельные батчи. Примечание: используем **zstd (level 1)** вместо zlib, так как он обеспечивает более высокую скорость декомпрессии и лучшее сжатие в современных GP-кластерах (6.0+). |
| **AO Row** zstd | ODS snapshot: `airports`, `airplanes`, `routes`, `seats` | Полный snapshot каждый раз. Перевести загрузку с UPSERT на TRUNCATE+INSERT — честнее для семантики «текущий срез». |
| **AO Row** zstd | `dds.dim_tariffs` | Только INSERT новых тарифов, UPDATE не используется. |
| **Heap** (явный) | ODS транзакционные: `bookings`, `tickets`, `flights`, `segments`, `boarding_passes` | Row-level UPDATE при SCD1 UPSERT. Heap обязателен. |
| **Heap** (явный) | DDS измерения с UPDATE: `dim_airports`, `dim_airplanes`, `dim_passengers`, `dim_routes` | SCD1/SCD2 UPSERT с row-level UPDATE. |
| **Heap** (явный) | `dds.fact_flight_sales` | UPDATE (is_boarded, seat_no меняются). |
| **Heap** (явный) | DM витрины с UPSERT: `sales_report` и будущие HWM-витрины | Row-level UPDATE при инкрементальном UPSERT. |
**Учебная ценность**: студенты видят на практике три storage-стратегии в одном проекте:
1. AO Column — для иммутабельных аналитических таблиц (dim_calendar, route_performance)
2. AO Row — для append-only данных и snapshot-справочников (STG, ODS refs, dim_tariffs)
3. Heap — для таблиц с row-level UPDATE (ODS транзакции, DDS dims с UPSERT, факт, DM)
И понимают **почему** выбор именно такой: UPDATE на AO = bloat + необходимость VACUUM.
---
## ЧТО ОСТАВИТЬ КАК ЕСТЬ
| Аспект | Почему не трогаем |
|--------|-------------------|
| Факт без SK | Правильное моделирование, нужен только комментарий (P1) |
| ODS DAG с 20 задачами | Не перегружает — параллельная структура наглядна на графе |
| 5 витрин DM | Правильное количество, каждая учит своему паттерну |
| PL/pgSQL DQ | Достаточно для учебного проекта, фреймворк — перебор |
| MAX+ROW_NUMBER для SK | Корректно для GP, нужен только комментарий (P1) |
---
## Сводка по трудозатратам
Все задачи P0–P2 выполнены. P3 отложены (покрыты другими документами).
| Приоритет | Действие | Статус |
|-----------|----------|--------|
| ~~P0~~ | ODS batch resolver: разделить логику для справочников и транзакций | ✅ |
| ~~P0~~ | Исправить distribution key в airport_traffic | ✅ |
| ~~P1~~ | Добавить 7 точечных комментариев | ✅ |
| ~~P2~~ | Явный storage type + AO где нет UPDATE (ADR-3) | ✅ |
| ~~P2~~ | Рефакторинг hashdiff → TEMP TABLE | ✅ |
| ~~P2~~ | Переименовать STG поля в канон | ✅ |
| ~~P2~~ | TEMP TABLE для сложных ODS load-ов | ✅ |
| ~~P2~~ | Реализовать DM слой (5 витрин) | ✅ |
| P3 | Маршрут изучения DDS | Покрыто: `analyst_spec.md` |
| P3 | DQ-функции, distribution strategy, паттерны | Отложено |
+52
View File
@@ -0,0 +1,52 @@
# Учебные задания
> Это ветка `solution` — здесь всё уже реализовано.
> Используйте её как справочник, а задания выполняйте на ветке `main`.
## Навигатор по реализации
На этой ветке полностью работает пайплайн `STG → ODS → DDS → DM`.
Ниже — рекомендуемый порядок изучения: от простых паттернов к сложным.
### ODS: загрузка из STG
| Паттерн | Файл | Что посмотреть |
|---------|------|----------------|
| TRUNCATE + INSERT (snapshot-справочник) | `sql/ods/airports_load.sql` | Фильтрация по `stg_batch_id`, дедупликация через `ROW_NUMBER()` |
| SCD1 UPSERT (транзакционные данные) | `sql/ods/bookings_load.sql` | TEMP TABLE → UPDATE (`IS DISTINCT FROM`) → INSERT, HWM |
### DDS: измерения и факт
| Паттерн | Файл | Что посмотреть |
|---------|------|----------------|
| SCD1 (простое измерение) | `sql/dds/dim_airports_load.sql` | UPSERT с суррогатным ключом |
| SCD1 с обогащением | `sql/dds/dim_airplanes_load.sql` | JOIN с `ods.seats` для `total_seats` |
| SCD2 (версионирование) | `sql/dds/dim_routes_load.sql` | `hashdiff`, `valid_from`/`valid_to`, денормализация |
| Факт с point-in-time JOIN | `sql/dds/fact_flight_sales_load.sql` | SCD2 lookup, защитные LEFT JOIN |
### DM: витрины
| Паттерн | Файл | Что посмотреть |
|---------|------|----------------|
| UPSERT с HWM по датам | `sql/dm/sales_report_load.sql` | Инкрементальная агрегация фактов |
| Full Rebuild (AO Column) | `sql/dm/route_performance_load.sql` | TRUNCATE + INSERT, агрегация по SCD2 `route_bk` |
| UNION ALL (dual-role dimension) | `sql/dm/airport_traffic_load.sql` | Разворот факта: departure + arrival |
| Двухуровневая агрегация | `sql/dm/monthly_overview_load.sql` | Честный `avg_load_factor` через 2 уровня |
| Метод затронутых ключей | `sql/dm/passenger_loyalty_load.sql` | HWM по пассажирам, пересчёт полной истории |
## Техническое задание
Основной документ: **[analyst_spec.md](analyst_spec.md)** — ТЗ от аналитика
с описанием всех таблиц, маппингами, бизнес-правилами и подсказками.
## Описания DAG-ов
- [STG](../bookings_to_gp_stage.md) ·
[ODS](../bookings_to_gp_ods.md) ·
[DDS](../bookings_to_gp_dds.md) ·
[DM](../bookings_to_gp_dm.md)
- [Порядок запуска DAG-ов](../reference/dag_execution_order.md)
## Для менторов
Дизайн заданий и педагогическая логика: [docs/design/assignment_design.md](../design/assignment_design.md).
+652
View File
@@ -0,0 +1,652 @@
# Техническое задание: расширение DWH авиаперевозок
> **Роль:** Вы — Data Engineer. Это ТЗ подготовлено аналитиком на основе
> бизнес-потребностей. Ваша задача — реализовать описанные таблицы и загрузки,
> опираясь на эталонный срез (витрина `dm.sales_report` и вся её цепочка).
>
> **Эталон для изучения:**
> - STG: весь слой — `bookings`, `tickets`, `flights`, `segments`, `boarding_passes`,
> `airports`, `airplanes`, `seats`, `routes` (реализован как образец)
> - ODS: `bookings`, `tickets`, `segments`, `flights`, `boarding_passes`, `airports`, `routes`
> - DDS: `dim_airports`, `dim_tariffs`, `dim_calendar`, `fact_flight_sales`
> - DM: `sales_report`
>
> SQL-скрипты эталона лежат в `sql/stg/`, `sql/ods/`, `sql/dds/`, `sql/dm/`.
---
## Рекомендуемый порядок выполнения
> **Примечание:** STG-слой полностью реализован в эталоне (все 9 таблиц), `ods.routes`
> тоже эталонный. Ваше задание начинается с ODS: `airplanes` и `seats`.
1. **ODS** — `airplanes`, `seats` (практика TRUNCATE+INSERT по аналогии с `ods.airports`)
2. **DDS** — `dim_airplanes`, `dim_passengers` (SCD1 — новые измерения)
3. **DDS** — `dim_routes` (SCD2 — ключевой вызов курсовой)
4. **DM** — `airport_traffic` (простая витрина, похожа на `sales_report`)
5. **DM** — `route_performance` (Full Rebuild, работа с SCD2-измерением)
6. **DM** — `monthly_overview` (двухуровневая агрегация)
7. **DM** — `passenger_loyalty` (самая сложная, пересчёт истории)
Порядок выстроен от простого к сложному. Каждый шаг опирается на опыт
предыдущего. Вы можете двигаться в ином порядке, но убедитесь, что зависимости
слоёв соблюдены (ODS → DDS → DM).
---
## Общие правила
- **Нейминг полей:** см. `docs/design/naming_conventions.md` — единый источник
истины для служебных полей (`_load_id`, `_load_ts`, `created_at`, `updated_at`,
`valid_from`, `valid_to`, `hashdiff`, суффиксы `_bk` / `_sk`).
- **SQL-файлы:** располагайте в `sql/{слой}/{объект}_{роль}.sql`
(например, `sql/stg/airplanes_ddl.sql`, `sql/stg/airplanes_load.sql`).
- **DAG-интеграция:** используйте `PostgresOperator` + путь к SQL-файлу.
Добавьте таски в существующие DAG-файлы соответствующего слоя.
- **Идемпотентность:** каждый скрипт загрузки должен быть безопасен
при повторном запуске (не создавать дубликатов).
- **Шаблон `{{ run_id }}`:** используйте Jinja-шаблон Airflow для `_load_id`.
---
## Почему STG-слой уже реализован
STG-слой (все 9 таблиц) и `ods.routes` полностью реализованы в эталоне. Причина:
Эталонный пайплайн использует **batch resolver** — механизм, который находит
согласованный набор данных во всех четырёх snapshot-таблицах (`stg.airports`,
`stg.airplanes`, `stg.routes`, `stg.seats`) по одному `_load_id`. Если любая
из этих таблиц пуста — batch resolver не найдёт общего батча, и весь ODS pipeline
не запустится.
Это означает: без заполненного STG невозможна загрузка ODS и всей последующей
цепочки (DDS, DM). Поэтому весь STG реализован как эталон — чтобы пайплайн
работал с первого запуска.
`ods.routes` тоже эталонный: факт `fact_flight_sales` использует его для резолвинга
аэропортов вылета/прилёта, независимо от студенческого `dds.dim_routes`.
**Что вам делать:** Изучите эталонные скрипты как образец — именно так написан
«боевой» код загрузки:
- `sql/stg/airports_load.sql` — инкрементальная загрузка (HWM)
- `sql/stg/airplanes_load.sql` — full snapshot с проверкой `_load_id`
- `sql/ods/airports_load.sql` — TRUNCATE+INSERT из STG
---
## Часть 1. ODS-слой (Operational Data Store)
> **Цель:** Привести данные из STG к целевым типам, очистить, дедуплицировать.
> Справочники (`airplanes`, `seats`) загружаются стратегией TRUNCATE + INSERT
> из последнего согласованного батча STG. (`ods.routes` реализован в эталоне.)
>
> **Аналог для изучения:** `sql/ods/airports_ddl.sql`, `sql/ods/airports_load.sql`
### 1.1. ods.airplanes
**Описание:** Очищенный справочник моделей воздушных судов с правильными типами.
**Источник:** `stg.airplanes`
| Поле | Тип | Описание | Маппинг из STG |
|------|-----|----------|----------------|
| `airplane_code` | TEXT NOT NULL | Код модели (PK) | `airplane_code` |
| `model` | TEXT NOT NULL | Наименование модели | `model` (парсинг JSON: `model::JSON->>'ru'` — если JSON, иначе `model` как есть) |
| `range_km` | INTEGER | Дальность полёта, км | `range::INTEGER` |
| `speed_kmh` | INTEGER | Крейсерская скорость, км/ч | `speed::INTEGER` |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (текущее состояние справочника, TRUNCATE + INSERT).
**Стратегия загрузки:** TRUNCATE + INSERT (полный снимок из STG).
**Бизнес-правила:**
- Привести `range` и `speed` из TEXT в INTEGER.
- Если `model` хранится в JSON-формате — извлечь русское название (`->>'ru'`).
Изучите, как это сделано для `airports` в эталоне.
**Тип хранения Greenplum:** Append-Only Row.
**Distribution Key:** `airplane_code`
---
### 1.2. ods.seats
**Описание:** Карта посадочных мест с корректными типами.
**Источник:** `stg.seats`
| Поле | Тип | Описание | Маппинг из STG |
|------|-----|----------|----------------|
| `airplane_code` | TEXT NOT NULL | Код модели (PK, часть 1) | `airplane_code` |
| `seat_no` | TEXT NOT NULL | Номер места (PK, часть 2) | `seat_no` |
| `fare_conditions` | TEXT NOT NULL | Класс обслуживания | `fare_conditions` |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (текущее состояние справочника, TRUNCATE + INSERT).
**Стратегия загрузки:** TRUNCATE + INSERT.
**Бизнес-правила:**
- Составной PK: `(airplane_code, seat_no)`.
- Значения `fare_conditions` ограничены: `Economy`, `Business`, `Comfort`.
**Тип хранения Greenplum:** Append-Only Row.
**Distribution Key:** `airplane_code`
---
---
## Часть 2. DDS-слой (Detailed Data Store) — Измерения
> **Цель:** Построить измерения звёздной схемы (Star Schema) с суррогатными
> ключами. SCD1-измерения обновляют атрибуты «на месте». SCD2-измерение
> хранит историю изменений через версионирование.
>
> **Аналог для SCD1:** `sql/dds/dim_airports_ddl.sql`, `sql/dds/dim_airports_load.sql`
### 2.1. dds.dim_airplanes (SCD1)
**Описание:** Измерение моделей самолётов. Содержит технические характеристики
и рассчитанное общее количество мест (обогащение из `ods.seats`).
**Источники:** `ods.airplanes` + `ods.seats`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `airplane_sk` | INTEGER NOT NULL | Суррогатный ключ | Генерация: `MAX(airplane_sk) + ROW_NUMBER()` |
| `airplane_bk` | TEXT NOT NULL | Бизнес-ключ (код модели) | `ods.airplanes.airplane_code` |
| `model` | TEXT NOT NULL | Название модели | `ods.airplanes.model` |
| `range_km` | INTEGER | Дальность полёта, км | `ods.airplanes.range_km` |
| `speed_kmh` | INTEGER | Скорость, км/ч | `ods.airplanes.speed_kmh` |
| `total_seats` | INTEGER | Общее кол-во мест | `COUNT(ods.seats.*) по airplane_code` |
| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT |
| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** SCD1 (обновление атрибутов без версионирования).
**Гранулярность:** Одна строка = одна модель самолёта.
**Стратегия загрузки:** UPSERT (UPDATE существующих + INSERT новых).
- UPDATE: если атрибуты (`model`, `range_km`, `speed_kmh`, `total_seats`)
изменились (проверка через `IS DISTINCT FROM`).
- INSERT: если `airplane_bk` ещё не существует в `dim_airplanes`.
**Обогащение:** Поле `total_seats` рассчитывается как количество строк
в `ods.seats` для данного `airplane_code` (LEFT JOIN).
**Генерация суррогатного ключа:** `MAX(airplane_sk) + ROW_NUMBER()`.
Безопасно при `concurrency=1` в DAG.
**Distribution Key:** `airplane_sk`
---
### 2.2. dds.dim_passengers (SCD1)
**Описание:** Измерение пассажиров. Извлекается из таблицы билетов — каждый
уникальный `passenger_id` становится строкой измерения.
**Источник:** `ods.tickets`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `passenger_sk` | INTEGER NOT NULL | Суррогатный ключ | Генерация: `MAX(passenger_sk) + ROW_NUMBER()` |
| `passenger_id` | TEXT NOT NULL | Идентификатор пассажира (BK) | `ods.tickets.passenger_id` |
| `passenger_name` | TEXT NOT NULL | ФИО пассажира | `ods.tickets.passenger_name` |
| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT |
| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** SCD1.
**Гранулярность:** Одна строка = один уникальный пассажир.
**Стратегия загрузки:** UPSERT.
- Из `ods.tickets` один пассажир может встречаться в нескольких билетах.
Необходима дедупликация: берём последнее (актуальное) имя.
**Подсказка:** используйте `ROW_NUMBER() OVER (PARTITION BY passenger_id ORDER BY event_ts DESC NULLS LAST, _load_ts DESC, ticket_no DESC)`
для детерминированного выбора самой свежей записи.
- UPDATE: если `passenger_name` изменилось.
- INSERT: если `passenger_id` ещё не существует.
**Бизнес-правила:**
- `passenger_id` — бизнес-ключ. Один пассажир может иметь несколько билетов,
но в измерении должна быть ровно одна строка.
- Имя обновляется, если изменилось в последнем билете (SCD1).
**Distribution Key:** `passenger_sk`
---
### 2.3. dds.dim_routes (SCD2)
> **Это ключевой вызов курсовой.** Реализация SCD Type 2 — обязательный навык
> для Data Engineer. Ниже — алгоритм текстом; SQL вы пишете самостоятельно.
> Если застряли — сверьтесь с веткой `solution`.
**Описание:** Измерение авиамаршрутов с полной историей изменений. Если у маршрута
меняется самолёт, аэропорт или расписание — создаётся новая версия, а старая
закрывается. Это позволяет видеть, какой маршрут действовал на момент конкретного
рейса.
**Источники:** `ods.routes` + `dds.dim_airports` + `dds.dim_airplanes`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `route_sk` | INTEGER NOT NULL | Суррогатный ключ | Генерация: `MAX(route_sk) + ROW_NUMBER()` |
| `route_bk` | TEXT NOT NULL | Бизнес-ключ маршрута | `ods.routes.route_no` |
| `departure_airport` | TEXT NOT NULL | Код аэропорта вылета | `ods.routes.departure_airport` |
| `arrival_airport` | TEXT NOT NULL | Код аэропорта прилёта | `ods.routes.arrival_airport` |
| `airplane_code` | TEXT NOT NULL | Код модели самолёта | `ods.routes.airplane_code` |
| `departure_city` | TEXT NOT NULL | Город вылета (денормализация) | `dds.dim_airports.city` по `departure_airport` |
| `arrival_city` | TEXT NOT NULL | Город прилёта (денормализация) | `dds.dim_airports.city` по `arrival_airport` |
| `airplane_model` | TEXT NOT NULL | Модель самолёта (денормализация) | `dds.dim_airplanes.model` |
| `total_seats` | INTEGER NOT NULL | Кол-во мест (денормализация) | `dds.dim_airplanes.total_seats` |
| `days_of_week` | TEXT | Дни недели | `ods.routes.days_of_week` (приведение к TEXT) |
| `departure_time` | TIME | Время вылета | `ods.routes.departure_time` |
| `duration` | INTERVAL | Длительность полёта | `ods.routes.duration` |
| `hashdiff` | TEXT NOT NULL | Хэш версионируемых атрибутов | См. формулу ниже |
| `valid_from` | DATE NOT NULL | Начало действия версии | Первая версия: `'1900-01-01'`; последующие: `CURRENT_DATE` |
| `valid_to` | DATE | Конец действия версии (NULL = текущая) | `NULL` для актуальных, `CURRENT_DATE` при закрытии |
| `created_at` | TIMESTAMP NOT NULL | Дата создания версии | `now()` при INSERT |
| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** SCD2 (версионирование с полуоткрытым интервалом).
**Гранулярность:** Одна строка = одна версия маршрута.
#### Формула hashdiff
```sql
md5(concat_ws('|',
departure_airport,
arrival_airport,
airplane_code,
days_of_week,
departure_time,
duration
))
```
Хэш считается по атрибутам, изменение которых означает «новую версию маршрута».
Денормализованные поля (`departure_city`, `arrival_city`, `airplane_model`,
`total_seats`) **не входят** в `hashdiff` — они обновляются отдельно (SCD1-refresh),
не создавая новую версию.
#### Алгоритм SCD2 (пошагово)
1. **Подготовка:** Рассчитайте `hashdiff` для каждого маршрута из `ods.routes`.
2. **Найдите изменения:** Сравните `hashdiff` текущих версий
(`valid_to IS NULL`) с новыми значениями из ODS.
3. **Закройте устаревшие версии:** Для маршрутов, у которых `hashdiff` изменился,
выполните UPDATE: `valid_to = CURRENT_DATE`, `updated_at = now()`.
4. **Закройте исчезнувшие маршруты:** Если маршрут был в DDS (`valid_to IS NULL`),
но отсутствует в ODS — тоже закройте: `valid_to = CURRENT_DATE`.
5. **Вставьте новые версии:** INSERT строки с `valid_to = NULL`
для изменённых и новых маршрутов. Значение `valid_from` зависит от того,
встречался ли маршрут в DDS ранее:
- **Первая версия** (маршрут ещё не было в DDS): `valid_from = '1900-01-01'`.
Sentinel-дата покрывает всю историю полётов, чтобы point-in-time JOIN
фактов на исторические рейсы находил корректный `route_sk`.
- **Последующие версии** (маршрут уже был): `valid_from = CURRENT_DATE`.
6. **SCD1-обновление денормализованных полей:** Обновите `departure_city`,
`arrival_city`, `airplane_model`, `total_seats` для ВСЕХ открытых версий
(даже если `hashdiff` не менялся).
**Интервалы версий:** полуоткрытые `[valid_from, valid_to)`.
Текущая (актуальная) версия: `valid_to IS NULL`.
**Distribution Key:** `route_sk`
---
## Часть 3. DM-слой (Data Marts) — Витрины
> **Цель:** Построить аналитические витрины поверх DDS.
> Каждая витрина отвечает на конкретный бизнес-вопрос.
>
> **Аналог для изучения:** `sql/dm/sales_report_ddl.sql`, `sql/dm/sales_report_load.sql`
### 3.1. dm.airport_traffic
**Бизнес-вопрос:** «Какой пассажиропоток и выручка у каждого аэропорта по дням?»
**Описание:** Ежедневная статистика по каждому аэропорту: сколько рейсов
вылетело/прилетело, сколько пассажиров, какая выручка. Аэропорт выступает
в двойной роли — и как точка вылета, и как точка прилёта.
**Источники:** `dds.fact_flight_sales` + `dds.dim_airports` + `dds.dim_calendar`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `traffic_date` | DATE NOT NULL | Дата (зерно, часть 1) | `dim_calendar.date_actual` |
| `airport_sk` | INTEGER NOT NULL | Суррогатный ключ аэропорта (зерно, часть 2) | `fact.departure_airport_sk` / `fact.arrival_airport_sk` (UNION ALL) |
| `airport_bk` | TEXT NOT NULL | Код аэропорта (денормализация) | `dim_airports.airport_bk` |
| `city` | TEXT NOT NULL | Город (денормализация) | `dim_airports.city` |
| `departures_flights` | INTEGER NOT NULL | Кол-во рейсов на вылет | `COUNT(DISTINCT flight_id)` роль «departure» |
| `departures_passengers` | INTEGER NOT NULL | Пассажиры на вылет | `SUM(is_boarded)` роль «departure» |
| `departures_revenue` | NUMERIC(15,2) NOT NULL | Выручка по вылетам | `SUM(price)` роль «departure» |
| `arrivals_flights` | INTEGER NOT NULL | Кол-во рейсов на прилёт | `COUNT(DISTINCT flight_id)` роль «arrival» |
| `arrivals_passengers` | INTEGER NOT NULL | Пассажиры на прилёт | `SUM(is_boarded)` роль «arrival» |
| `arrivals_revenue` | NUMERIC(15,2) NOT NULL | Выручка по прилётам | `SUM(price)` роль «arrival» |
| `total_passengers` | INTEGER NOT NULL | Общий пассажиропоток (вылет + прилёт) | `SUM(is_boarded)` обе роли |
| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT |
| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (UPSERT — текущее состояние метрик за день).
**Гранулярность:** Одна строка = один аэропорт за один день.
**Стратегия загрузки:** Инкрементальный UPSERT с HWM по `fact_flight_sales._load_ts`.
**Ключевой приём — Unpivot (UNION ALL):**
Каждый факт продажи порождает два «события»: вылет (departure) и прилёт (arrival).
Используйте UNION ALL для разворота факта в два ряда — по `departure_airport_sk`
и `arrival_airport_sk`. Затем сгруппируйте по `(traffic_date, airport_sk)`.
**Метрики:**
- `departures_flights` — `COUNT(DISTINCT flight_id)` для роли «вылет»
- `departures_passengers` — `SUM(is_boarded)` для роли «вылет»
- `departures_revenue` — `SUM(price)` для роли «вылет»
- Аналогично для прилётов
- `total_passengers` — сумма всех `is_boarded` (обе роли)
**Важно:** Выручка специально разделена на `departures_revenue` и `arrivals_revenue`.
Суммировать их нельзя — это приведёт к двойному счёту (один билет учитывается и
в аэропорту вылета, и в аэропорту прилёта).
**Тип хранения Greenplum:** Heap (для UPSERT).
**Distribution Key:** `airport_sk`
---
### 3.2. dm.route_performance
**Бизнес-вопрос:** «Какие маршруты самые эффективные? Где высокий load factor,
а где теряем пассажиров?»
**Описание:** Сводная статистика эффективности каждого маршрута за всё время.
Агрегация идёт по бизнес-ключу маршрута (`route_bk`), чтобы собрать данные
со всех исторических версий (SCD2).
**Источники:** `dds.fact_flight_sales` + `dds.dim_routes` + `dds.dim_calendar`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `route_bk` | TEXT NOT NULL | Бизнес-ключ маршрута (зерно) | `dim_routes.route_bk` (GROUP BY) |
| `route_sk` | INTEGER NOT NULL | SK актуальной версии маршрута | `dim_routes.route_sk` WHERE `valid_to IS NULL` |
| `departure_airport_bk` | TEXT NOT NULL | Код аэропорта вылета (денормализация) | `dim_routes.departure_airport` (актуальная версия) |
| `departure_city` | TEXT NOT NULL | Город вылета (денормализация) | `dim_routes.departure_city` (актуальная версия) |
| `arrival_airport_bk` | TEXT NOT NULL | Код аэропорта прилёта (денормализация) | `dim_routes.arrival_airport` (актуальная версия) |
| `arrival_city` | TEXT NOT NULL | Город прилёта (денормализация) | `dim_routes.arrival_city` (актуальная версия) |
| `airplane_bk` | TEXT NOT NULL | Код модели самолёта (денормализация) | `dim_routes.airplane_code` (актуальная версия) |
| `airplane_model` | TEXT NOT NULL | Модель самолёта (денормализация) | `dim_routes.airplane_model` (актуальная версия) |
| `total_seats` | INTEGER NOT NULL | Кол-во мест в самолёте (денормализация) | `dim_routes.total_seats` (актуальная версия) |
| `total_flights` | INTEGER NOT NULL | Всего рейсов | `COUNT(DISTINCT fact.flight_id)` |
| `total_tickets` | INTEGER NOT NULL | Всего проданных билетов | `COUNT(*)` |
| `total_boarded` | INTEGER NOT NULL | Всего посадок | `SUM(CASE WHEN is_boarded THEN 1 ELSE 0 END)` |
| `total_revenue` | NUMERIC(15,2) NOT NULL | Суммарная выручка | `SUM(fact.price)` |
| `avg_ticket_price` | NUMERIC(10,2) | Средняя цена билета | `total_revenue / NULLIF(total_tickets, 0)` |
| `avg_boarding_rate` | NUMERIC(5,4) NOT NULL | Средняя доля посадок | `AVG(CASE WHEN is_boarded THEN 1 ELSE 0 END)` |
| `avg_load_factor` | NUMERIC(5,4) | Средняя заполняемость кресел | `total_boarded / NULLIF(total_flights * total_seats, 0)` |
| `first_flight_date` | DATE | Дата первого рейса | `MIN(dim_calendar.date_actual)` |
| `last_flight_date` | DATE | Дата последнего рейса | `MAX(dim_calendar.date_actual)` |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (Full Rebuild — полная перезагрузка каждый запуск).
**Гранулярность:** Одна строка = один маршрут (по `route_bk`).
**Стратегия загрузки:** Full Rebuild (TRUNCATE + INSERT).
Витрина небольшая (~1000 строк) — проще пересоздать, чем вычислять дельту.
**Обработка SCD2:** Факты связаны с разными версиями маршрута (`route_sk`).
Агрегируйте метрики по `route_bk` (бизнес-ключу), чтобы собрать статистику
со ВСЕХ версий. Денормализованные атрибуты берите из ТЕКУЩЕЙ версии
(`valid_to IS NULL`).
**Метрики:**
- `avg_ticket_price` — `total_revenue / total_tickets` (защита от деления на 0
через `NULLIF`)
- `avg_boarding_rate` — `AVG(CASE WHEN is_boarded THEN 1 ELSE 0 END)`
- `avg_load_factor` — `total_boarded / (total_flights * total_seats)`
**Тип хранения Greenplum:** AO Column Store (zstd). Идеален для аналитики:
отличное сжатие, чтение только нужных колонок. AO не поддерживает UPDATE —
поэтому используем Full Rebuild.
**Distribution Key:** `route_bk`
**Служебные поля:** `created_at` / `updated_at` здесь не нужны — при Full Rebuild
все строки пересоздаются. Достаточно `_load_ts`.
---
### 3.3. dm.monthly_overview
**Бизнес-вопрос:** «Какова помесячная динамика: рейсы, выручка, load factor
в разрезе типов самолётов?»
**Описание:** Помесячная сводка с точным расчётом средней заполняемости (load factor)
через двухуровневую агрегацию. Разрез — по типу самолёта.
**Источники:** `dds.fact_flight_sales` + `dds.dim_calendar` + `dds.dim_airplanes`
+ `dds.dim_routes`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `year_actual` | INTEGER NOT NULL | Год (зерно, часть 1) | `dim_calendar.year_actual` |
| `month_actual` | INTEGER NOT NULL | Месяц (зерно, часть 2) | `dim_calendar.month_actual` |
| `airplane_sk` | INTEGER NOT NULL | SK типа самолёта (зерно, часть 3) | `fact.airplane_sk` |
| `airplane_bk` | TEXT NOT NULL | Код модели (денормализация) | `dim_airplanes.airplane_bk` |
| `airplane_model` | TEXT NOT NULL | Название модели (денормализация) | `dim_airplanes.model` |
| `total_seats` | INTEGER NOT NULL | Кол-во мест (денормализация) | `dim_airplanes.total_seats` |
| `total_flights` | INTEGER NOT NULL | Кол-во уникальных рейсов | `COUNT(DISTINCT fact.flight_id)` |
| `total_tickets` | INTEGER NOT NULL | Кол-во проданных билетов | `SUM(tickets_sold_per_flight)` (уровень 2) |
| `total_boarded` | INTEGER NOT NULL | Кол-во посадок | `SUM(boarded_per_flight)` (уровень 2) |
| `total_revenue` | NUMERIC(15,2) NOT NULL | Суммарная выручка | `SUM(revenue_per_flight)` (уровень 2) |
| `avg_ticket_price` | NUMERIC(10,2) | Средняя цена билета | `total_revenue / NULLIF(total_tickets, 0)` |
| `avg_load_factor` | NUMERIC(5,4) | Средняя заполняемость кресел | `AVG(flight_load_factor)` (уровень 2) |
| `unique_routes` | INTEGER NOT NULL | Кол-во уникальных маршрутов | `COUNT(DISTINCT dim_routes.route_bk)` |
| `unique_passengers` | INTEGER NOT NULL | Кол-во уникальных пассажиров | `COUNT(DISTINCT fact.passenger_sk)` |
| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT |
| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (UPSERT — текущее состояние метрик за месяц).
**Гранулярность:** Одна строка = один месяц + один тип самолёта.
**Стратегия загрузки:** Инкрементальный UPSERT с HWM по `fact_flight_sales._load_ts`.
Пересчитываются только затронутые месяцы.
**Ключевой приём — Двухуровневая агрегация:**
Чтобы честно посчитать среднюю заполняемость (`avg_load_factor`), нельзя просто
поделить `SUM(boarded)` на `SUM(seats)` — это даёт ошибку (парадокс Симпсона).
Правильный путь:
1. **Уровень 1 (рейс):** Для каждого `flight_id` посчитайте `load_factor = boarded / total_seats`.
2. **Уровень 2 (месяц):** Возьмите `AVG(load_factor)` по всем рейсам месяца.
**Подсчёт уникальных маршрутов:** Используйте `COUNT(DISTINCT route_bk)` из
`dim_routes` (т.к. `dim_routes` — SCD2, у одного маршрута может быть несколько
`route_sk`).
**Антипаттерн (Distribution Key):**
Распределение по `(year_actual, month_actual)` — это ошибка. В MPP-системах
распределение по дате ведёт к Data Skew (весь месяц на одном сегменте).
Используйте `airplane_sk`.
**Тип хранения Greenplum:** Heap (для UPSERT).
**Distribution Key:** `airplane_sk`
---
### 3.4. dm.passenger_loyalty
**Бизнес-вопрос:** «Кто наши самые лояльные пассажиры? Сколько они летают,
тратят, какой класс предпочитают?»
**Описание:** Профиль лояльности каждого пассажира: накопительные метрики
за всю историю перелётов. Самая сложная витрина — требует пересчёта
полной истории для затронутых пассажиров.
**Источники:** `dds.fact_flight_sales` + `dds.dim_passengers` + `dds.dim_tariffs`
+ `dds.dim_routes` + `dds.dim_calendar`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `passenger_sk` | INTEGER NOT NULL | SK пассажира (зерно) | `fact.passenger_sk` |
| `passenger_bk` | TEXT NOT NULL | Идентификатор пассажира (денормализация) | `dim_passengers.passenger_id` |
| `passenger_name` | TEXT NOT NULL | ФИО (денормализация) | `dim_passengers.passenger_name` |
| `total_bookings` | INTEGER NOT NULL | Кол-во бронирований (`book_ref`) | `COUNT(DISTINCT fact.book_ref)` |
| `total_flights` | INTEGER NOT NULL | Кол-во перелётов | `COUNT(*)` |
| `total_boarded` | INTEGER NOT NULL | Кол-во успешных посадок | `SUM(CASE WHEN is_boarded THEN 1 ELSE 0 END)` |
| `total_spent` | NUMERIC(15,2) NOT NULL | Общие траты | `SUM(fact.price)` |
| `avg_ticket_price` | NUMERIC(10,2) | Средняя цена билета | `total_spent / NULLIF(total_flights, 0)` |
| `favorite_fare_conditions` | TEXT | Самый частый класс обслуживания | Мода по `dim_tariffs.fare_conditions` |
| `unique_routes` | INTEGER NOT NULL | Кол-во уникальных маршрутов | `COUNT(DISTINCT dim_routes.route_bk)` |
| `first_flight_date` | DATE | Дата первого перелёта | `MIN(dim_calendar.date_actual)` |
| `last_flight_date` | DATE | Дата последнего перелёта | `MAX(dim_calendar.date_actual)` |
| `days_as_customer` | INTEGER | Стаж клиента (дней между первым и последним) | `last_flight_date - first_flight_date` |
| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT |
| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (UPSERT — текущий профиль пассажира).
**Гранулярность:** Одна строка = один пассажир.
**Стратегия загрузки:** Инкрементальный UPSERT по «затронутым ключам».
**Ключевой приём — Метод затронутых ключей:**
Это не обычный HWM по датам. Алгоритм:
1. Найдите `passenger_sk`, чьи факты изменились (HWM по `fact._load_ts`).
2. Для этих пассажиров **пересчитайте ВСЮ историю** — все их перелёты от начала.
3. UPSERT результаты.
Почему? Потому что метрики накопительные (`total_spent`, `first_flight_date`).
Нельзя просто добавить дельту — нужен полный пересчёт для корректности.
**Метрики:**
- `total_bookings` — `COUNT(DISTINCT book_ref)` по всем перелётам пассажира
- `favorite_fare_conditions` — мода (самое частое значение).
**Подсказка:** используйте `DISTINCT ON` с `ORDER BY COUNT(*) DESC`.
- `unique_routes` — `COUNT(DISTINCT route_bk)` (не `route_sk`! т.к. `dim_routes` — SCD2)
- `days_as_customer` — `last_flight_date - first_flight_date`
- `avg_ticket_price` — `total_spent / total_flights`
**Фильтрация NULL:** В `fact_flight_sales` поле `passenger_sk` может быть NULL
(защитная фильтрация от неконсистентных фактов). Исключите такие строки:
`WHERE passenger_sk IS NOT NULL`.
**Тип хранения Greenplum:** Heap (для UPSERT).
**Distribution Key:** `passenger_sk`
---
## Пересчёт факта после реализации измерений
После того, как вы реализуете все DDS-измерения (`dim_airplanes`, `dim_passengers`,
`dim_routes`), нужно пересчитать факт — он загружался ещё без ваших SK:
1. Запустите загрузку своих измерений (dim_airplanes, dim_passengers, dim_routes)
2. Выполните `TRUNCATE dds.fact_flight_sales;`
3. Перезапустите загрузку факта (DAG или только таск `load_dds_fact_flight_sales`)
4. Перезапустите DM-витрины
```sql
-- Проверка: все SK заполнены
SELECT
COUNT(*) AS total,
COUNT(departure_airport_sk) AS has_dep_sk,
COUNT(arrival_airport_sk) AS has_arr_sk,
COUNT(route_sk) AS has_route_sk,
COUNT(passenger_sk) AS has_passenger_sk,
COUNT(airplane_sk) AS has_airplane_sk
FROM dds.fact_flight_sales;
```
Это стандартная практика при **late-arriving dimensions** (опаздывающих измерениях):
факт загрузился раньше, чем были готовы справочники, поэтому SK заполнены не были.
После пересчёта все SK проставятся корректно.
---
## Валидация
После реализации каждого слоя запустите валидационный DAG `bookings_validate`
в Airflow UI. Он проверит:
- Таблицы существуют и содержат данные
- PK не содержат NULL
- SCD2: корректность `valid_from`/`valid_to`, отсутствие «дыр» в версиях
- Кросс-слойная консистентность (ODS vs STG по кол-ву записей)
- DM-витрины содержат данные за загруженные дни
Сообщения об ошибках укажут, что именно не так и что делать дальше.
---
## Приложение: ER-диаграмма (источник)
```
bookings.airplanes_data bookings.seats
airplane_code (PK) airplane_code (FK) ──┐
model (JSON) seat_no │
range fare_conditions │
speed │
│ │
└──────────────────────────────────────────────┘
│
bookings.routes
route_no (PK, part 1)
validity (PK, part 2)
departure_airport (FK → airports_data)
arrival_airport (FK → airports_data)
airplane_code (FK → airplanes_data)
days_of_week
scheduled_time
duration
bookings.flights
flight_id (PK)
route_no (FK → routes)
status
scheduled_departure / scheduled_arrival
actual_departure / actual_arrival
bookings.bookings ─── bookings.tickets ─── bookings.segments
book_ref (PK) ticket_no (PK) ticket_no (FK)
book_date book_ref (FK) flight_id (FK)
total_amount passenger_id fare_conditions
passenger_name price
outbound
bookings.boarding_passes
ticket_no (FK)
flight_id (FK)
seat_no
boarding_no
boarding_time
```
+184
View File
@@ -0,0 +1,184 @@
# DAG `bookings_to_gp_dds`: `ods` → `dds` в Greenplum
Этот DAG — учебный пример загрузки аналитического слоя **DDS** (Star Schema) из текущего состояния **ODS**.
Здесь сосредоточены ключевые паттерны аналитического хранилища: SCD1, SCD2 с hashdiff,
point-in-time join, защитные LEFT JOIN для устойчивости к data quality аномалиям.
## Что делает DAG
- Загружает 6 измерений DDS:
- `dds.dim_calendar` — статическое измерение дат (Full Rebuild);
- `dds.dim_airports`, `dds.dim_airplanes`, `dds.dim_tariffs`, `dds.dim_passengers` — SCD1 UPSERT;
- `dds.dim_routes` — **SCD2** с `hashdiff`, `valid_from`, `valid_to` + денормализация.
- Загружает факт `dds.fact_flight_sales` — инкрементальный UPSERT по зерну `(ticket_no, flight_id)`.
- Для каждой таблицы выполняет пару задач `load → dq`.
- Использует `_load_id = {{ run_id }}`. DDS не требует `stg_batch_id`, потому что читает
текущее состояние ODS.
## Что должно быть готово перед запуском
1) Стенд поднят:
```bash
make up
```
2) STG и ODS уже загружены:
- выполнены DAG-и `bookings_to_gp_stage` и `bookings_to_gp_ods`;
- DDL-объекты созданы (`bookings_dds_ddl` или `make ddl-gp`).
## Как запустить
1) Откройте Airflow UI: http://localhost:8080.
2) Если запускаете DDS впервые — выполните `bookings_dds_ddl`.
3) Запустите `bookings_to_gp_dds`.
## Граф зависимостей
```
load_dds_dim_calendar → dq_dds_dim_calendar
├─ load_dds_dim_airports → dq_dds_dim_airports ─┐
│ ├─ load_dds_dim_routes
├─ load_dds_dim_airplanes → dq_dds_dim_airplanes ─┘ └─ dq_dds_dim_routes
│ │
├─ load_dds_dim_tariffs → dq_dds_dim_tariffs │
│ │
└─ load_dds_dim_passengers → dq_dds_dim_passengers │
│
все 5 dq_dds_dim_* ─────────────────────────────────────────┘
└─ load_dds_fact_flight_sales
└─ dq_dds_fact_flight_sales
└─ finish_dds_summary
```
Ключевой момент: `dim_routes` зависит от `dim_airports` и `dim_airplanes` (денормализация),
а факт ждёт завершения **всех** пяти измерений.
## Как это работает внутри (по шагам)
### 1) `load_dds_dim_calendar` → `dq_dds_dim_calendar`
- **SQL:** `sql/dds/dim_calendar_load.sql`, `sql/dds/dim_calendar_dq.sql`
- **Паттерн:** Full Rebuild — каждый запуск пересоздаёт календарь целиком.
Измерение маленькое и детерминированное, дельту считать нет смысла.
### 2–5) SCD1-измерения (параллельно после calendar)
| # | Задача | SQL-файлы | Что загружает |
|---|--------|-----------|---------------|
| 2 | `load_dds_dim_airports` → `dq_dds_dim_airports` | `sql/dds/dim_airports_load.sql`, `sql/dds/dim_airports_dq.sql` | Аэропорты (код, город, координаты) |
| 3 | `load_dds_dim_airplanes` → `dq_dds_dim_airplanes` | `sql/dds/dim_airplanes_load.sql`, `sql/dds/dim_airplanes_dq.sql` | Самолёты (код, модель, кол-во мест) |
| 4 | `load_dds_dim_tariffs` → `dq_dds_dim_tariffs` | `sql/dds/dim_tariffs_load.sql`, `sql/dds/dim_tariffs_dq.sql` | Тарифы (класс обслуживания) |
| 5 | `load_dds_dim_passengers` → `dq_dds_dim_passengers` | `sql/dds/dim_passengers_load.sql`, `sql/dds/dim_passengers_dq.sql` | Пассажиры (ID, имя, контакты) |
Паттерн загрузки — SCD1 UPSERT: TEMP TABLE → UPDATE (IS DISTINCT FROM) → INSERT.
### 6) `load_dds_dim_routes` → `dq_dds_dim_routes` (SCD2)
- **SQL:** `sql/dds/dim_routes_load.sql`, `sql/dds/dim_routes_dq.sql`
- **Паттерн:** SCD2 — самый нетривиальный паттерн в проекте. Работает в 3 фазы:
**Фаза 1. Hashdiff и закрытие старых версий.**
Скрипт считает MD5-хеш от шести бизнес-атрибутов маршрута (`departure_airport`, `arrival_airport`,
`airplane_code`, `days_of_week`, `departure_time`, `duration`).
Если хеш текущей версии в DDS не совпадает с хешем из ODS — старая версия закрывается
(`valid_to = CURRENT_DATE`). Также закрываются маршруты, исчезнувшие из ODS.
**Фаза 2. Вставка новых версий.**
Для изменённых и совершенно новых маршрутов создаётся новая строка.
`valid_from` выставляется в `1900-01-01` для первой версии маршрута и `CURRENT_DATE` для версии 2+.
Суррогатный ключ (`route_sk`) генерируется через `MAX(route_sk) + ROW_NUMBER()`.
> **Важно:** такая генерация SK безопасна только при `max_active_runs=1` (Airflow гарантирует
> последовательный запуск). В боевых системах используют sequence.
**Фаза 3. Обновление денормализованных атрибутов.**
`dim_routes` хранит денормализованные SCD1-атрибуты из `dim_airports` (города)
и `dim_airplanes` (модель, кол-во мест). Если, например, город переименовали —
фаза 3 обновляет **все** версии маршрута (и текущие, и исторические),
при этом `_load_id` и `_load_ts` не перезаписываются (lineage версий сохраняется).
### 7) `load_dds_fact_flight_sales` → `dq_dds_fact_flight_sales`
- **SQL:** `sql/dds/fact_flight_sales_load.sql`, `sql/dds/fact_flight_sales_dq.sql`
- **Зерно:** `(ticket_no, flight_id)` — один билет на один рейс.
- **Паттерн:** инкрементальный UPSERT.
Три учебных приёма в этом скрипте:
**Защитные LEFT JOIN (defensive coding).**
Все JOIN-ы с измерениями — `LEFT JOIN`. DAG гарантирует, что все измерения загружены
и прошли DQ **до** старта факта (жёсткие зависимости в графе). Поэтому в штатном режиме
NULL SK не возникают. LEFT JOIN здесь — защита от data quality аномалий (например, если
в `ods.routes` появится маршрут с несуществующим аэропортом).
DQ-проверки факта отражают эту логику:
- `passenger_sk` и `tariff_sk` — **запрещены** NULL целиком (0 строк);
- route-related FK (`route_sk`, `airport_sk`, `airplane_sk`) и `calendar_sk` —
допускается до **1%** NULL (NOTICE-предупреждение), при превышении — EXCEPTION.
> Это **не** паттерн late-arriving dimensions (опаздывающих измерений) в классическом
> понимании: backfill NULL SK при повторном запуске не реализован.
> В боевых системах для этого используют «строку-заглушку» (unknown member, SK = 0)
> и отдельный процесс backfill.
**Два пути lookup для аэропортов и маршрутов.**
Аэропорты (`departure_airport_sk`, `arrival_airport_sk`) разрешаются через `ods.routes` →
`dim_airports`. Аэропорты вылета/прилёта одинаковы во всех версиях маршрута, поэтому
point-in-time логика не нужна — безопасно брать актуальную версию из ODS.
`route_sk` и `airplane_sk` разрешаются через point-in-time join с SCD2 `dim_routes`:
```sql
LEFT JOIN dds.dim_routes AS rte
ON rte.route_bk = flt.route_no
AND flt.scheduled_departure::DATE >= rte.valid_from
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
```
Это гарантирует, что факт привязывается к той версии маршрута, которая была актуальна
на дату рейса.
**UPDATE мутабельных полей.**
UPDATE обновляет только `seat_no`, `price`, `is_boarded` (данные, которые реально
могут измениться — посадка пассажира, корректировка цены). SK измерений не перезаписываются —
они зафиксированы на момент вставки.
### 8) `finish_dds_summary`
Ждёт завершения DQ факта и логирует сводку.
## Как проверить результат
```bash
make gp-psql
```
```sql
SELECT COUNT(*) FROM dds.dim_calendar;
SELECT COUNT(*) FROM dds.dim_routes;
SELECT COUNT(*) FROM dds.fact_flight_sales;
-- Проверка: кол-во строк факта ≈ кол-во строк ODS segments
SELECT
(SELECT COUNT(*) FROM dds.fact_flight_sales) AS fact_rows,
(SELECT COUNT(*) FROM ods.segments) AS ods_rows;
-- Проверка SCD2: текущие версии маршрутов (valid_to IS NULL)
SELECT COUNT(*) AS current_versions,
(SELECT COUNT(*) FROM dds.dim_routes) AS total_versions
FROM dds.dim_routes
WHERE valid_to IS NULL;
```
Ожидаемо: `fact_rows = ods_rows`, `current_versions ≤ total_versions`.
## Типичные ошибки
- `relation "dds..." does not exist`:
- не применён DDS DDL (`bookings_dds_ddl` или `make ddl-gp`).
- DQ падает на `dim_routes`:
- проверьте согласованность `ods.routes` (дубли/аномальные версии) и перезапустите DAG.
- DQ падает на `fact_flight_sales` по coverage:
- проверьте, что ODS DAG завершился успешно без пропуска задач.
+171
View File
@@ -0,0 +1,171 @@
# DAG `bookings_to_gp_dm`: `dds` → `dm` в Greenplum
Этот DAG — учебный пример загрузки слоя **DM** (Data Mart / витрины) из текущего состояния **DDS**.
Все 5 витрин загружаются **параллельно** и демонстрируют разные стратегии загрузки —
это ключевая учебная ценность данного DAG.
## Что делает DAG
Загружает 5 витрин параллельно, для каждой — пара `load → dq`:
| Витрина | Зерно (grain) | Паттерн загрузки |
|---------|---------------|------------------|
| `dm.sales_report` | (flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk) | Инкрементальный UPSERT (HWM по датам) |
| `dm.route_performance` | route_bk | Full Rebuild (TRUNCATE + INSERT) |
| `dm.passenger_loyalty` | passenger_sk | Инкрементальный UPSERT (HWM по затронутым ключам) |
| `dm.airport_traffic` | (traffic_date, airport_sk) | Инкрементальный UPSERT (HWM по датам) |
| `dm.monthly_overview` | (year_actual, month_actual, airplane_sk) | Инкрементальный UPSERT (HWM по месяцам) |
## Что должно быть готово перед запуском
1) Стенд поднят:
```bash
make up
```
2) STG, ODS и DDS уже загружены:
- выполнены DAG-и `bookings_to_gp_stage`, `bookings_to_gp_ods`, `bookings_to_gp_dds`;
- DDL-объекты созданы (`bookings_dm_ddl` или `make ddl-gp`).
## Как запустить
1) Откройте Airflow UI: http://localhost:8080.
2) Если запускаете DM впервые — выполните `bookings_dm_ddl`.
3) Запустите `bookings_to_gp_dm`.
## Граф зависимостей
```
start_dm
├── load_dm_sales_report → dq_dm_sales_report ─┐
├── load_dm_route_performance → dq_dm_route_performance ─┤
├── load_dm_passenger_loyalty → dq_dm_passenger_loyalty ─┼─ finish_dm_summary
├── load_dm_airport_traffic → dq_dm_airport_traffic ─┤
└── load_dm_monthly_overview → dq_dm_monthly_overview ─┘
```
Все 5 веток полностью независимы и работают параллельно.
## Как это работает внутри (по шагам)
### 1) `load_dm_sales_report` → `dq_dm_sales_report`
- **SQL:** `sql/dm/sales_report_load.sql`, `sql/dm/sales_report_dq.sql`
- **Паттерн:** инкрементальный UPSERT (HWM по датам).
Витрина агрегирует продажи билетов по дате, аэропортам вылета/прилёта и тарифу.
Инкрементальность работает через HWM: витрина сравнивает свой `MAX(_load_ts)` с `_load_ts`
фактов в DDS и пересчитывает агрегаты только для **затронутых дат**.
> Если витрина пуста — `1900-01-01` заберёт всю историю (первичная загрузка).
> Если DAG не запускался несколько дней — при следующем запуске витрина автоматически
> «догонит» всю накопленную дельту.
Учебные приёмы:
- **TEMP TABLE** для однократной агрегации (канон для MPP);
- **NULLIF** для защиты от деления на ноль (`boarding_rate = boarded / NULLIF(sold, 0)`);
- **Денормализация**: города и коды аэропортов тянутся в витрину из измерений.
### 2) `load_dm_route_performance` → `dq_dm_route_performance`
- **SQL:** `sql/dm/route_performance_load.sql`, `sql/dm/route_performance_dq.sql`
- **Паттерн:** Full Rebuild (TRUNCATE + INSERT).
Витрина агрегирует эффективность маршрутов за всю историю. Таблица маленькая (~1000 строк),
поэтому пересоздать её с нуля дешевле, чем вычислять дельту. Дополнительная причина:
таблица хранится в формате **AO Column Store**, который не поддерживает эффективный UPDATE/DELETE.
Трёхшаговый паттерн:
1. **TRUNCATE** — очистка (единственный эффективный способ для AO).
2. **Агрегация** по `route_bk` — факты суммируются через **все исторические версии** маршрута
(route_sk из SCD2), чтобы не терять данные при версионировании.
3. **JOIN** с текущей (актуальной, `valid_to IS NULL`) версией `dim_routes` для денормализации.
> Благодаря денормализации `dim_routes` — один JOIN вместо четырёх
> (аэропорты вылета/прилёта, самолёт уже хранятся в `dim_routes`).
Метрики: `avg_load_factor = total_boarded / (total_flights * total_seats)`,
`avg_ticket_price = total_revenue / total_tickets`.
### 3) `load_dm_passenger_loyalty` → `dq_dm_passenger_loyalty`
- **SQL:** `sql/dm/passenger_loyalty_load.sql`, `sql/dm/passenger_loyalty_dq.sql`
- **Паттерн:** инкрементальный UPSERT по «затронутым ключам».
В отличие от `sales_report` (где инкремент по датам), здесь HWM находит **конкретных пассажиров**
с новыми фактами, а затем пересчитывает для них всю историю. Это гарантирует точность
накопительных агрегатов (`total_spent`, `first/last_flight_date`).
Учебные приёмы:
- **DISTINCT ON** (PostgreSQL-специфика) для нахождения моды (самый частый тариф пассажира);
- **Агрегация SCD2 по BK**: при подсчёте уникальных маршрутов используем `route_bk`,
а не `route_sk`, т.к. один маршрут может иметь несколько версий;
- Фильтрация `passenger_sk IS NOT NULL` — защита от неконсистентных фактов (NULL SK
при data quality аномалиях в измерениях).
### 4) `load_dm_airport_traffic` → `dq_dm_airport_traffic`
- **SQL:** `sql/dm/airport_traffic_load.sql`, `sql/dm/airport_traffic_dq.sql`
- **Паттерн:** инкрементальный UPSERT по датам.
Витрина показывает пассажиропоток аэропортов по дням (вылеты + прилёты).
Учебный приём — **Dual-role dimension через UNION ALL**: один билет превращается
в два «события» (вылет из одного аэропорта и прилёт в другой).
Это позволяет собрать единую статистику аэропорта (departures + arrivals) в одном проходе.
### 5) `load_dm_monthly_overview` → `dq_dm_monthly_overview`
- **SQL:** `sql/dm/monthly_overview_load.sql`, `sql/dm/monthly_overview_dq.sql`
- **Паттерн:** инкрементальный UPSERT по месяцам.
Витрина показывает помесячную статистику по типам самолётов.
Учебный приём — **двухуровневая агрегация**: чтобы честно посчитать `avg_load_factor`,
сначала считаем load factor для каждого рейса (`boarded / total_seats`),
затем берём среднее по месяцу. Прямая агрегация `SUM(boarded) / SUM(seats)` дала бы
искажённый результат (взвешенный по числу билетов, а не рейсов).
> **Ограничение SCD1:** `total_seats` берётся из текущего состояния `dim_airplanes`.
> Если самолёт переоборудовали в прошлом, для точного исторического расчёта
> потребовалось бы SCD2-измерение.
### 6) `start_dm` / `finish_dm_summary`
- `start_dm` — стартовый sentinel, от которого расходятся все 5 параллельных веток.
- `finish_dm_summary` — ждёт завершения всех DQ-задач и логирует сводку.
## Как проверить результат
```bash
make gp-psql
```
```sql
SELECT COUNT(*) FROM dm.sales_report;
SELECT COUNT(*) FROM dm.route_performance;
SELECT COUNT(*) FROM dm.passenger_loyalty;
SELECT COUNT(*) FROM dm.airport_traffic;
SELECT COUNT(*) FROM dm.monthly_overview;
-- Инвариант sales_report: посаженных не больше, чем продано
SELECT COUNT(*) FROM dm.sales_report WHERE tickets_sold < passengers_boarded;
-- Нет дублей по бизнес-ключу route_performance
SELECT route_bk, COUNT(*) FROM dm.route_performance GROUP BY route_bk HAVING COUNT(*) > 1;
```
Ожидаемо: все витрины непусты, инварианты соблюдены, дублей нет.
## Типичные ошибки
- `relation "dm..." does not exist`:
- не применён DM DDL (`bookings_dm_ddl` или `make ddl-gp`).
- DQ падает на `sales_report` по `boarding_rate`:
- проверьте, что DDS загрузился корректно (`dds.fact_flight_sales` непуста).
- DQ падает на `passenger_loyalty` с ошибкой FK:
- проверьте, что `dds.dim_passengers` содержит всех пассажиров из факта.
- `finish_dm_summary` не выполняется:
- одна из DQ-задач упала; найдите в логах Airflow задачу с ошибкой и исправьте.
+176
View File
@@ -0,0 +1,176 @@
# DAG `bookings_to_gp_ods`: `stg` → `ods` в Greenplum
Этот DAG — учебный пример загрузки типизированного слоя **ODS** из уже подготовленного слоя **STG**.
Логика каноничная: **TRUNCATE + INSERT** для snapshot-справочников, **SCD1 UPSERT** для транзакционных
таблиц + DQ-проверки.
## Что делает DAG
- Определяет `stg_batch_id` — последний согласованный батч, по которому все 4 snapshot-справочника
(`airports`, `airplanes`, `routes`, `seats`) уже приехали в STG.
- Загружает 9 таблиц ODS: `bookings`, `tickets`, `airports`, `airplanes`, `routes`, `seats`,
`flights`, `segments`, `boarding_passes`.
- Для каждой таблицы выполняет пару задач `load → dq`.
- Snapshot-справочники фильтруются по `stg_batch_id`, транзакционные таблицы — по HWM (`_load_ts`).
- Для snapshot-справочников дополнительно синхронизирует ключи (удаляет из ODS записи,
отсутствующие в выбранном STG-батче).
- Для `flights` дополнительно добирает рейсы из истории `stg.flights`, если на них
ссылаются `stg.segments` (чтобы сохранить ссылочную целостность `segments.flight_id → flights.flight_id`).
## Что должно быть готово перед запуском
1) Стек поднят:
```bash
make up
```
2) STG-слой создан и заполнен:
- запущен `bookings_stg_ddl` (или `make ddl-gp`);
- хотя бы один раз выполнен DAG `bookings_to_gp_stage`.
3) ODS-таблицы созданы (один из вариантов):
- учебный: запустить DAG `bookings_ods_ddl`;
- шорткат: `make ddl-gp` (создаёт и STG, и ODS).
## Как запустить
1) Откройте Airflow UI: http://localhost:8080.
2) Запустите DAG `bookings_to_gp_ods`.
3) (Опционально) передайте `stg_batch_id` в конфиге запуска:
```json
{"stg_batch_id": "manual__2026-02-22T12:00:00+00:00"}
```
Если конфиг не передан, DAG автоматически возьмёт последний согласованный snapshot-батч.
## Граф зависимостей
```
resolve_stg_batch_id
├─ load_ods_bookings → dq_ods_bookings
│ └─ load_ods_tickets → dq_ods_tickets ──────────────────┐
│ │
├─ load_ods_airports → dq_ods_airports ─┐ │
│ ├─ load_ods_routes │
├─ load_ods_airplanes → dq_ods_airplanes ─┤ └─ dq_ods_routes
│ │ └─ load_ods_flights
│ │ └─ dq_ods_flights ─┐
│ │ │
│ │ dq_ods_flights + dq_ods_tickets
│ │ └─ load_ods_segments
│ │ └─ dq_ods_segments
│ │ └─ load_ods_boarding_passes
│ │ └─ dq_ods_boarding_passes ─┐
│ │ │
│ └─ load_ods_seats │
│ └─ dq_ods_seats ─────────────────────────────────┤
│ │
└────────────────────────────────────────────────────────────────── finish_ods_summary ◀──────────┘
```
Ветка `seats` работает параллельно с веткой `routes → flights → segments → boarding_passes`.
Обе ветки сходятся на `finish_ods_summary`.
## Как это работает внутри (по шагам)
### 1) `resolve_stg_batch_id` (Python)
Определяет, какой STG-батч использовать для snapshot-справочников.
Если `stg_batch_id` не передан через `dag_run.conf`, ищет последний **согласованный** батч —
`_load_id`, который есть одновременно во всех четырёх snapshot-таблицах
(`stg.airports`, `stg.airplanes`, `stg.routes`, `stg.seats`).
Для этого используется **INTERSECT** по `_load_id`.
> **Зачем согласованность?** Чтобы ODS загружал только те данные, для которых приехали
> ВСЕ связанные справочники. Иначе возможна потеря ссылочной целостности при сборке витрин.
### 2–10) Загрузка 9 таблиц: `load_ods_*` → `dq_ods_*`
Каждая пара задач работает одинаково:
| # | Задача | SQL-файл | Тип загрузки |
|---|--------|----------|--------------|
| 2 | `load_ods_bookings` → `dq_ods_bookings` | `sql/ods/bookings_load.sql`, `sql/ods/bookings_dq.sql` | HWM (инкремент) |
| 3 | `load_ods_tickets` → `dq_ods_tickets` | `sql/ods/tickets_load.sql`, `sql/ods/tickets_dq.sql` | HWM (инкремент) |
| 4 | `load_ods_airports` → `dq_ods_airports` | `sql/ods/airports_load.sql`, `sql/ods/airports_dq.sql` | snapshot по `stg_batch_id` |
| 5 | `load_ods_airplanes` → `dq_ods_airplanes` | `sql/ods/airplanes_load.sql`, `sql/ods/airplanes_dq.sql` | snapshot по `stg_batch_id` |
| 6 | `load_ods_routes` → `dq_ods_routes` | `sql/ods/routes_load.sql`, `sql/ods/routes_dq.sql` | snapshot по `stg_batch_id` |
| 7 | `load_ods_seats` → `dq_ods_seats` | `sql/ods/seats_load.sql`, `sql/ods/seats_dq.sql` | snapshot по `stg_batch_id` |
| 8 | `load_ods_flights` → `dq_ods_flights` | `sql/ods/flights_load.sql`, `sql/ods/flights_dq.sql` | HWM (инкремент) |
| 9 | `load_ods_segments` → `dq_ods_segments` | `sql/ods/segments_load.sql`, `sql/ods/segments_dq.sql` | HWM (инкремент) |
| 10 | `load_ods_boarding_passes` → `dq_ods_boarding_passes` | `sql/ods/boarding_passes_load.sql`, `sql/ods/boarding_passes_dq.sql` | HWM (инкремент) |
### Два паттерна загрузки
В ODS используются **два разных паттерна** — выбор зависит от типа данных и формата хранения:
**Snapshot-справочники** (`airports`, `airplanes`, `routes`, `seats`) — **TRUNCATE + INSERT**:
1. **TRUNCATE** — полная очистка таблицы.
2. **INSERT** — вставка всех строк из STG-батча (`_load_id = stg_batch_id`) с дедупликацией
через `ROW_NUMBER()`.
> Почему не UPSERT? Эти таблицы хранятся в формате **AO Row** (`appendonly=true`),
> который не поддерживает эффективный row-level UPDATE (вызывает bloat).
> Для маленьких справочников (~100–300 строк) полная перезагрузка быстрее и чище.
**Транзакционные таблицы** (`bookings`, `tickets`, `flights`, `segments`, `boarding_passes`) —
**SCD1 UPSERT**:
1. **TEMP TABLE** — собирает дельту (новые/изменённые строки) с дедупликацией внутри батча
через `ROW_NUMBER()`. Временная таблица автоматически удаляется (`ON COMMIT DROP`).
2. **UPDATE** — обновляет существующие строки. Использует `IS DISTINCT FROM` для корректного
сравнения `NULL`-значений (обычный `<>` не обнаружит изменение `NULL → значение`).
3. **INSERT** — добавляет новые строки (которых нет в ODS по бизнес-ключу).
Транзакционные таблицы фильтруются по **HWM** — `WHERE _load_ts > (SELECT MAX(_load_ts) FROM ods.table)`.
Это сделано, чтобы не потерять инкременты, если STG-DAG запускался несколько раз
до запуска ODS-DAG'а.
### DQ-проверки (одинаковый паттерн)
Каждый `*_dq.sql` — PL/pgSQL-блок (`DO $$...$$`), который проверяет:
- нет дублей по бизнес-ключу в ODS;
- все ключи из STG текущего батча присутствуют в ODS;
- обязательные поля не содержат NULL.
При ошибке — `RAISE EXCEPTION` с понятным текстом. Для инкрементальных таблиц пустой батч допустим.
### 11) `finish_ods_summary`
Ждёт завершения обеих параллельных веток (`dq_ods_boarding_passes` и `dq_ods_seats`)
и логирует краткую сводку.
## Как проверить результат
```bash
make gp-psql
```
```sql
SELECT COUNT(*) FROM ods.bookings;
SELECT COUNT(*) FROM ods.tickets;
SELECT COUNT(*) FROM ods.flights;
-- Проверка: в ODS не должно быть дублей по бизнес-ключу
SELECT book_ref, COUNT(*)
FROM ods.bookings
GROUP BY 1
HAVING COUNT(*) > 1;
```
Ожидаемо: в последнем запросе `0` строк.
## Типичные ошибки
- `stg_batch_id не найден`:
- передайте `stg_batch_id` в `dag_run.conf`, или
- сначала загрузите STG через `bookings_to_gp_stage`.
- Ошибки `relation "ods...." does not exist`:
- не применён ODS DDL (`bookings_ods_ddl` / `make ddl-gp`).
- Ошибки DQ по ссылочной целостности:
- проверьте, что ODS DAG выполнялся с корректным `stg_batch_id` и без пропуска upstream задач.
+156
View File
@@ -0,0 +1,156 @@
# DAG `bookings_to_gp_stage`: `bookings-db` → `stg` в Greenplum
Этот DAG — основной учебный пример в стенде. Он показывает путь данных из источника **Postgres**
(`bookings-db`, демо‑БД `demo`) в сырой слой **STG** в **Greenplum** с инкрементальной загрузкой
и простыми проверками качества данных.
## Что делает DAG
- В источнике (`bookings-db`) генерирует следующий учебный день данных в `bookings.bookings`
(генератор всегда “шагает” вперёд от `max(book_date)`).
- В Greenplum загружает инкремент в `stg.bookings` через внешнюю таблицу `stg.bookings_ext`, используя PXF.
- Сверяет количество строк между источником (за окно инкремента) и загруженным батчем.
- Загружает инкремент в `stg.tickets` через внешнюю таблицу `stg.tickets_ext`, используя PXF.
- Запускает DQ‑проверки для `stg.tickets` (количество, ссылочная целостность, обязательные поля).
- Загружает справочники (full load): `stg.airports`, `stg.airplanes`, `stg.routes`, `stg.seats` + DQ.
- Загружает транзакции: `stg.flights` (инкремент), `stg.segments` (инкремент), `stg.boarding_passes` (инкремент через tickets/bookings) + DQ.
## Что должно быть готово перед запуском
1) Стек поднят:
```bash
make up
```
2) Демо‑БД bookings установлена и содержит данные:
```bash
make bookings-init
```
3) В Greenplum созданы STG‑объекты (внешние `*_ext` через PXF и внутренние таблицы слоя `stg`)
для всех таблиц потока: `bookings`, `tickets`, `airports`, `airplanes`, `routes`, `seats`, `flights`,
`segments`, `boarding_passes` (выберите один вариант):
- учебный вариант: запустить DAG `bookings_stg_ddl` в Airflow UI;
- технический шорткат: `make ddl-gp`.
4) Airflow Connections:
- `bookings_db` — подключение к источнику `bookings-db`;
- `greenplum_conn` — подключение к Greenplum.
По умолчанию они задаются через переменные окружения `AIRFLOW_CONN_...` в `docker-compose.yml`,
поэтому могут не отображаться в UI — для `PostgresOperator` это нормально.
## Как запустить
1) Откройте Airflow UI: http://localhost:8080 (admin/admin).
2) Запустите DAG `bookings_to_gp_stage` вручную (Trigger DAG).
3) Дождитесь статуса Success у всех задач.
## Как это работает внутри (по шагам)
1) `generate_bookings_day`
- выполняет `sql/src/bookings_generate_day_if_missing.sql` в `bookings-db`;
- если `bookings.bookings` пустая — вызывает `generate(...)` на `BOOKINGS_INIT_DAYS`;
- иначе — вызывает `continue(...)`, добавляя ровно один следующий день.
2) `load_bookings_to_stg`
- выполняет `sql/stg/bookings_load.sql` в Greenplum;
- берёт строки из `stg.bookings_ext`, которые попадают в новое окно инкремента;
- вставляет их в `stg.bookings`, добавляя тех.колонки:
- `event_ts` (опорная метка времени для инкремента),
- `_load_ts`,
- `_load_id={{ run_id }}`.
3) `check_row_counts`
- выполняет `sql/stg/bookings_dq.sql` в Greenplum;
- считает количество строк в источнике за то же окно инкремента и сравнивает с количеством строк,
вставленных в `stg.bookings` для текущего `_load_id`;
- при расхождении делает `RAISE EXCEPTION` с понятным текстом.
4) `load_tickets_to_stg`
- выполняет `sql/stg/tickets_load.sql` в Greenplum;
- так как в `bookings.tickets` нет явной временной колонки, окно инкремента берётся по `book_date`
из связанной внешней таблицы `stg.bookings_ext` (JOIN по `book_ref`);
- вставляет строки в `stg.tickets`, добавляя `event_ts`, `_load_ts` и `_load_id={{ run_id }}`.
5) `check_tickets_dq`
- выполняет `sql/stg/tickets_dq.sql` в Greenplum;
- проверяет количество строк в том же окне инкремента, а также ссылочную целостность и обязательные поля;
- при проблемах делает `RAISE EXCEPTION`, чтобы DAG падал “красным”.
6) Справочники (full load, параллельно где возможно)
Справочники загружаются “снэпшотом” (все строки) и затем проверяются DQ-скриптом.
Порядок определяется зависимостями данных — **airports** и **airplanes** грузятся **параллельно**,
потому что не зависят друг от друга:
```
check_tickets_dq
├─ load_airports → check_airports_dq ─┐
│ ├─ load_routes → check_routes_dq
└─ load_airplanes → check_airplanes_dq ─┤
└─ load_seats → check_seats_dq
```
- `load_airports_to_stg` → `check_airports_dq` (`sql/stg/airports_load.sql`, `sql/stg/airports_dq.sql`)
- `load_airplanes_to_stg` → `check_airplanes_dq` (`sql/stg/airplanes_load.sql`, `sql/stg/airplanes_dq.sql`)
- `load_routes_to_stg` → `check_routes_dq` (`sql/stg/routes_load.sql`, `sql/stg/routes_dq.sql`) — зависит от **airports** и **airplanes** (DQ проверяет ссылочную целостность)
- `load_seats_to_stg` → `check_seats_dq` (`sql/stg/seats_load.sql`, `sql/stg/seats_dq.sql`) — зависит от **airplanes** (DQ проверяет `airplane_code → airplanes`)
7) Транзакции
- `load_flights_to_stg` → `check_flights_dq` (инкремент по `scheduled_departure`) — зависит от **routes**
- `load_segments_to_stg` → `check_segments_dq` (инкремент по `book_date` через tickets/bookings) — зависит от **flights**
- `load_boarding_passes_to_stg` → `check_boarding_passes_dq` (инкремент по `book_date` через tickets/bookings) — зависит от **segments**
Ветка `seats` работает параллельно с веткой `routes → flights → segments → boarding_passes`.
Обе ветки сходятся на `finish_summary`.
Важно: для инкрементальных таблиц “пустое окно инкремента” допустимо — загрузка и DQ логируют `NOTICE` и завершаются успешно.
Для snapshot-справочников (airports/airplanes/routes/seats) пустой источник считается ошибкой (DQ делает `RAISE EXCEPTION`).
8) `finish_summary`
- ждёт завершения **обеих** параллельных веток (`check_boarding_passes_dq` и `check_seats_dq`);
- логирует краткую сводку в конце запуска.
## Как проверить результат
```bash
make gp-psql
```
Примеры запросов:
```sql
SELECT COUNT(*) FROM stg.bookings;
SELECT
event_ts,
_load_ts,
_load_id
FROM stg.bookings
ORDER BY event_ts DESC
LIMIT 10;
```
## Типичные ошибки
- `database "demo" does not exist`: демо‑БД не установлена → выполните `make bookings-init`.
- Ошибки про `stg.*`/`stg.*_ext`: не применён DDL → запустите `bookings_stg_ddl` или `make ddl-gp`.
- Ошибки PXF (`protocol "pxf" does not exist`, connection refused): перезапустите `greenplum` и повторите DDL.
Для технических деталей см. `docs/reference/pxf_bookings.md`.
## Рекомендации по качеству решения
Ревью решения и список улучшений, которые делают пайплайн более “эталонным” для обучения:
`docs/archive/bookings_stg_code_review.md`.
+35
View File
@@ -0,0 +1,35 @@
# Порядок запуска DAG (Cross-DAG Dependencies)
В этом стенде пайплайны разделены на несколько DAG-ов по слоям DWH (STG, ODS, DDS, DM).
Они настроены с `schedule=None`, так как это учебный проект.
Чтобы данные корректно прошли от источника до витрин, запускать DAG-и нужно в определённом порядке.
## 1. DDL-скрипты (выполняются один раз)
Для создания структуры таблиц в аналитических слоях:
1. Запустите `bookings_stg_ddl` — создаст STG-таблицы и внешние `*_ext` через PXF.
2. Запустите `bookings_ods_ddl` — создаст таблицы ODS (типизированные, SCD1).
3. Запустите `bookings_dds_ddl` — создаст таблицы для измерений и фактов в слое DDS.
4. Запустите `bookings_dm_ddl` — создаст таблицы витрин в слое DM.
*(Технический шорткат: `make ddl-gp` применяет DDL для всех 4 слоёв сразу).*
## 2. Ежедневная загрузка (ETL)
Для прогрузки новой порции данных (или полного перерасчёта) соблюдайте следующую цепочку:
1. **`bookings_to_gp_stage`**
- Извлекает новые данные из демо-БД PostgreSQL и сохраняет их в `stg`-схему в Greenplum.
- Генерирует `stg_batch_id` для текущей загрузки.
2. **`bookings_to_gp_ods`**
- Берёт последний согласованный `stg_batch_id` из STG-слоя.
- Выполняет нормализацию и SCD1-UPSERT в слой ODS.
3. **`bookings_to_gp_dds`**
- Читает очищенные данные из ODS.
- Обновляет измерения (SCD1, SCD2) и инкрементально догружает новые рейсы в таблицу фактов `dds.fact_flight_sales`.
4. **`bookings_to_gp_dm`**
- Читает новые факты из DDS.
- Обновляет агрегированные витрины (использует HWM-инкрементальность по `_load_ts` или полный перерасчёт).
> **💡 Архитектурная заметка:** В реальном production-окружении (Airflow) эти связи между DAG-ами обычно настраиваются автоматически через `TriggerDagRunOperator`, `ExternalTaskSensor` или механизмы Data-Aware Scheduling (Datasets/Data Assets). В учебных целях мы оставили их ручными, чтобы вы могли проинспектировать каждый слой после его загрузки.
+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». Финализировать.
+164
View File
@@ -0,0 +1,164 @@
# Дизайн курсового задания
> Тактические решения по нарезке задания, порядку выполнения и самопроверке.
> Стратегию и контекст см. в [PRD.md](PRD.md).
---
## 1. Эталонный срез: витрина `sales_report`
Эталоном выбрана витрина `dm.sales_report` и вся её цепочка вниз по слоям.
**Почему `sales_report`:**
- Покрывает SCD1 (airports, tariffs), HWM-инкремент, fact load
- Богатая денормализация — хороший образец для подражания
- Средняя сложность — не пугает, но и не тривиальна
### Эталонные таблицы (даны студенту)
| Слой | Таблицы |
|------|-----------------------------------------------------------------|
| DM | `sales_report` |
| DDS | `fact_flight_sales`, `dim_airports` (SCD1), `dim_tariffs` (SCD1), `dim_calendar` |
| ODS | `bookings`, `tickets`, `segments`, `flights`, `boarding_passes`, `airports`, `routes` |
| STG | весь слой: `bookings`, `tickets`, `segments`, `flights`, `boarding_passes`, `airports`, `airplanes`, `seats`, `routes` |
### Задание студенту
| Слой | Таблицы | Что нового для студента |
|------|-------------------------------------------------------------------|--------------------------------------------------|
| ODS | `airplanes`, `seats` | Практика TRUNCATE+INSERT по аналогии с эталоном |
| DDS | `dim_airplanes` (SCD1), `dim_passengers` (SCD1), `dim_routes` (SCD2) | **SCD2 — ключевой вызов курсовой** |
| DM | `airport_traffic`, `monthly_overview`, `route_performance`, `passenger_loyalty` | Разная сложность (от простой к сложной) |
---
## 2. Рекомендуемый порядок выполнения
Студенту рекомендуется (но не обязательно) двигаться в таком порядке:
1. **ODS** (airplanes, seats) — практика TRUNCATE+INSERT
2. **DDS** dim_airplanes, dim_passengers (SCD1) — новые измерения
3. **DDS** dim_routes (**SCD2**) — ключевой вызов
4. **DM** airport_traffic — простая витрина, похожа на sales_report
5. **DM** route_performance — TRUNCATE+INSERT, SCD2-агрегация по BK
6. **DM** monthly_overview — двухуровневая агрегация
7. **DM** passenger_loyalty — самая сложная, пересчёт истории
Порядок выстроен от простого к сложному. Каждый шаг опирается на опыт
предыдущего.
---
## 3. SCD2: подход «рецепт без готового SQL»
Реализация `dim_routes` (SCD2) — ключевой вызов курсовой. Студент делает это
самостоятельно, но ТЗ содержит пошаговую подсказку:
1. Алгоритм SCD2 текстом (без SQL):
- Вычисли `hashdiff` по набору атрибутов (атрибуты перечислены в ТЗ)
- Найди строки, у которых `hashdiff` изменился
- Закрой старую версию (`valid_to = текущая_дата`)
- Вставь новую версию (`valid_from = текущая_дата`, `valid_to = NULL`)
2. Формула hashdiff: `md5(concat_ws('|', field1, field2, ...))`
3. Ссылка на `naming_conventions.md` (поля `valid_from`, `valid_to`, `hashdiff`)
4. Напоминание: полуоткрытый интервал `[valid_from, valid_to)`
5. Если застрял — ветка `solution`
Самостоятельная реализация — ключ к запоминанию. SCD2 — обязательный вопрос
на собеседованиях DE, и студент должен уметь объяснить его на основе
собственного опыта.
---
## 4. Валидационный DAG (`bookings_validate`)
Отдельный DAG для самопроверки студента. Запускается вручную в Airflow UI
после реализации заданий. Таски сгруппированы по слоям — студент видит,
где именно проблема. Дополнительный бонус — практика чтения логов Airflow.
### Примерная структура тасков
```
bookings_validate
├── validate_ods
│ ├── check_ods_airplanes_rowcount (ODS >= STG по кол-ву уникальных BK)
│ ├── check_ods_seats_rowcount
│ └── check_ods_no_null_pks (PK not null)
├── validate_dds
│ ├── check_dim_airplanes_exists
│ ├── check_dim_passengers_exists
│ ├── check_dim_routes_scd2 (valid_from/valid_to корректны)
│ └── check_dim_routes_no_gaps (нет «дыр» в версиях SCD2)
└── validate_dm
├── check_airport_traffic_exists
├── check_monthly_overview_exists
├── check_route_performance_exists
└── check_passenger_loyalty_exists
```
### Реализация
- `PostgresOperator` + SQL-скрипты в `sql/validate/`
- Каждый SQL-скрипт выполняет SELECT и бросает исключение (через
`DO $$ ... RAISE EXCEPTION ... $$`), если проверка не пройдена
- Сообщения об ошибках — дружелюбные, с подсказкой что делать дальше
### Ключевые проверки
- Таблицы существуют и содержат данные
- PK не содержат NULL
- SCD2: `valid_to IS NULL` для текущих версий, нет перекрытий интервалов
- Кросс-слойная консистентность (row count ODS vs STG)
- DM-витрины содержат данные за загруженные дни
### Активная проверка SCD2 (`check_dim_routes_scd2`)
Справочник `bookings.routes` в демо-базе статичен — маршруты не меняются
между запусками генератора. Поэтому при обычном прогоне пайплайна студент
никогда не увидит, как SCD2 закрывает старую версию и создаёт новую.
Чтобы проверить корректность реализации, таск `check_dim_routes_scd2`
должен быть **активным** (не только читать, но и тестировать загрузку):
1. Сохранить текущее состояние `ods.routes` и `dds.dim_routes` (temp-таблицы).
2. Вставить в `ods.routes` тестовый маршрут с изменённым атрибутом
(например, `scheduled_time` → `departure_time` сдвинут на 1 час).
3. Вызвать студенческий SQL загрузки `dim_routes` (`sql/dds/dim_routes_load.sql`).
4. Проверить результат:
- Старая версия маршрута закрыта (`valid_to IS NOT NULL`).
- Новая версия открыта (`valid_to IS NULL`, `hashdiff` отличается).
- Нет «дыр» между `valid_to` старой и `valid_from` новой версии.
5. Откатить изменения: восстановить `ods.routes` и `dds.dim_routes`
из сохранённых temp-таблиц.
Это единственный способ гарантировать, что SCD2 работает, без мутации
источника (что сломало бы генератор `continue()`).
---
## 5. Формат ТЗ от аналитика
Файл: `docs/assignment/analyst_spec.md` (или несколько файлов по слоям).
Для каждой таблицы-задания документ содержит:
- **Имя таблицы** и целевая схема (stg / ods / dds / dm)
- **Описание** — что хранит таблица, бизнес-смысл
- **Список полей** с типами и описанием
- **Маппинг источников** — откуда берётся каждое поле
- **Бизнес-правила и фильтры** (если есть)
- **Тип историзации** (SCD1 / SCD2 / snapshot / append)
- **Гранулярность** (одна строка = ?)
- **Distribution key** (подсказка или задание на выбор)
Формат — приближен к реальным ТЗ, которые студент встретит на работе.
---
## 6. Развитие лабораторных
Перед добавлением или пересмотром заданий прочитайте
[заметку о развитии лабораторных](labs_development.md): там зафиксированы
аудитория, решение по PXF-практикуму, предложения по обязательному циклу
и общие критерии подготовки заданий.
File diff suppressed because it is too large. Load diff
+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`, `boarding_passes`) пустой батч допустим;
- для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) пустой батч считаем ошибкой.
---
## 7) DQ-проверки ODS (минимум, но строго)
Каждый DQ-скрипт должен:
- быть привязан к `stg_batch_id` (через XCom, как в load-скриптах);
- делать `RAISE EXCEPTION` при нарушении;
- давать понятную подсказку в тексте ошибки.
### 7.1. Обязательные проверки
1. **Нет дублей** по бизнес-ключу в ODS.
2. **Покрытие батча:** все ключи из STG текущего батча присутствуют в ODS.
3. **Обязательные поля** не `NULL`/не пустые.
4. **Батч не пустой** для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`): если STG-батч оказался пустым — это ошибка (источник недоступен или PXF не работает).
5. **Ссылочная целостность** в ODS:
- `tickets.book_ref -> bookings.book_ref`
- `flights.route_no -> routes.route_no` (упрощённая проверка: наличие `route_no` в routes, без учёта конкретной версии `validity`. Это допустимо, потому что в источнике flights ссылается на route_no, а не на конкретную версию маршрута. При downstream join (DDS) нужно будет учитывать период `validity`)
- `segments.ticket_no -> tickets.ticket_no`
- `segments.flight_id -> flights.flight_id`
- `boarding_passes (ticket_no, flight_id) -> segments (ticket_no, flight_id)`
### 7.2. Пример проверки покрытия батча
```sql
SELECT COUNT(*)
FROM (
SELECT DISTINCT book_ref
FROM stg.bookings
WHERE _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text
) s
WHERE NOT EXISTS (
SELECT 1
FROM ods.bookings o
WHERE o.book_ref = s.book_ref
);
```
Ожидаемый результат: `0`.
---
## 8) Структура файлов
Каждый `*_ddl.sql` начинается с `CREATE SCHEMA IF NOT EXISTS ods;` (по аналогии с STG DDL).
```text
sql/ods/
├── airports_ddl.sql
├── airports_load.sql
├── airports_dq.sql
├── airplanes_ddl.sql
├── airplanes_load.sql
├── airplanes_dq.sql
├── routes_ddl.sql
├── routes_load.sql
├── routes_dq.sql
├── seats_ddl.sql
├── seats_load.sql
├── seats_dq.sql
├── bookings_ddl.sql
├── bookings_load.sql
├── bookings_dq.sql
├── tickets_ddl.sql
├── tickets_load.sql
├── tickets_dq.sql
├── flights_ddl.sql
├── flights_load.sql
├── flights_dq.sql
├── segments_ddl.sql
├── segments_load.sql
├── segments_dq.sql
├── boarding_passes_ddl.sql
├── boarding_passes_load.sql
└── boarding_passes_dq.sql
sql/ddl_gp.sql (+ подключение sql/ods/*_ddl.sql)
airflow/dags/
├── bookings_ods_ddl.py
└── bookings_to_gp_ods.py
docs/bookings_to_gp_ods.md
Makefile (ddl-gp включает ODS DDL)
tests/test_dags_smoke.py (+ smoke для 2 новых DAG)
```
---
## 9) DAG `bookings_to_gp_ods`: учебный граф зависимостей
Принцип: у каждой сущности строго `load -> dq`, и только после `dq` разрешаем downstream.
Корневой таск `resolve_stg_batch_id` определяет батч (см. секцию 5.2), после чего две параллельных ветки стартуют одновременно:
```text
resolve_stg_batch_id
├-> load_ods_bookings -> dq_ods_bookings -> load_ods_tickets -> dq_ods_tickets ──────────────────┐
├-> load_ods_airports -> dq_ods_airports ─┐ │
├-> load_ods_airplanes -> dq_ods_airplanes ─┼-> load_ods_routes -> dq_ods_routes │
└-> └-> load_ods_seats -> dq_ods_seats │
│
dq_ods_routes -> load_ods_flights -> dq_ods_flights │
│
dq_ods_flights + dq_ods_tickets -> load_ods_segments -> dq_ods_segments
│
dq_ods_segments -> load_ods_boarding_passes -> dq_ods_boarding_passes
│
[dq_ods_boarding_passes, dq_ods_seats] -> finish_ods_summary
```
Зависимости (по FK):
- `tickets` после `bookings` (FK: `book_ref`);
- `routes` после `airports` и `airplanes` (FK: `departure_airport`, `arrival_airport`, `airplane_code`);
- `seats` после `airplanes` (FK: `airplane_code`);
- `flights` после `routes` (FK: `route_no`);
- `segments` после `flights` и `tickets` (FK: `flight_id`, `ticket_no`);
- `boarding_passes` после `segments` (FK: `ticket_no`, `flight_id`).
> Справочники (`airports`, `airplanes`) и транзакции (`bookings`) не зависят друг от друга в ODS — данные уже в STG. Поэтому они стартуют параллельно после `resolve_stg_batch_id`.
---
## 10) Порядок реализации
1. Подготовить DDL в `sql/ods/*_ddl.sql`.
2. Подключить `sql/ods/*_ddl.sql` в общий `sql/ddl_gp.sql`.
3. Использовать существующий `Makefile`-таргет `ddl-gp` для STG+ODS.
4. Создать DAG `bookings_ods_ddl.py`.
5. Реализовать `sql/ods/*_load.sql` (SCD1 UPSERT).
6. Реализовать `sql/ods/*_dq.sql`.
7. Создать DAG `bookings_to_gp_ods.py` (с параметром `stg_batch_id`).
8. Дописать smoke-тесты DAG в `tests/test_dags_smoke.py`.
9. Описать запуск и проверки в `docs/bookings_to_gp_ods.md`.
---
## 11) Критерии готовности (Definition of Done)
Готово, если:
1. Оба новых DAG парсятся и проходят smoke-тесты (`make test`).
2. `make ddl-gp` создаёт объекты STG+ODS без ошибок.
3. Для тестового `stg_batch_id` ODS-загрузка завершается успешно.
4. Все DQ-задачи зелёные и реально валят DAG при искусственной ошибке.
5. В ODS нет дублей по бизнес-ключам.
6. Нейминг техполей ODS консистентен с учебной статьёй: `_load_id`, `_load_ts`, `event_ts`.
---
## 12) Как проверять вручную
```bash
make up
make ddl-gp # создать STG+ODS-объекты
# Trigger bookings_to_gp_stage (загрузить STG)
# Передать stg_batch_id в conf (рекомендуется) или дать ODS DAG выбрать
# последний согласованный batch автоматически.
# Trigger bookings_to_gp_ods с conf: {"stg_batch_id": "<значение>"}
make gp-psql
```
Проверочные SQL:
```sql
-- 1) Дубликаты в ODS (пример bookings)
SELECT book_ref, COUNT(*)
FROM ods.bookings
GROUP BY 1
HAVING COUNT(*) > 1;
-- 2) Покрытие текущего STG-батча в ODS
SELECT COUNT(*)
FROM (
SELECT DISTINCT book_ref
FROM stg.bookings
WHERE _load_id = '<stg_batch_id>'
) s
WHERE NOT EXISTS (
SELECT 1
FROM ods.bookings o
WHERE o.book_ref = s.book_ref
);
-- 3) Ссылочная целостность tickets -> bookings
SELECT COUNT(*)
FROM ods.tickets t
WHERE NOT EXISTS (
SELECT 1
FROM ods.bookings b
WHERE b.book_ref = t.book_ref
);
```
Ожидаемо: все три запроса возвращают `0` проблемных строк.
---
## 13) Что будет следующим шагом
После стабилизации ODS:
- строим DDS;
- показываем SCD2 на измерениях DDS (`valid_from`/`valid_to`, `created_at`/`updated_at`) по тому же неймингу, который уже знаком студентам из `dwh-modeling`.
+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.
+315
View File
@@ -0,0 +1,315 @@
# Схема БД DWH (Bookings → Greenplum)
> **Статус:** Все слои реализованы в ветке `solution` (STG, ODS, DDS, DM).
> На ветке `main` студенческие таблицы ODS (`airplanes`, `seats`), DDS-измерения
> (`dim_airplanes`, `dim_passengers`, `dim_routes`) и студенческие DM-витрины
> (`airport_traffic`, `route_performance`, `monthly_overview`, `passenger_loyalty`)
> — заглушки (`SELECT 1;`). Данные появятся после реализации заданий.
Архитектура хранилища данных (DWH) для учебного проекта Airflow + Greenplum.
Источник — демо-БД `bookings` (Postgres). Документ даёт цельный взгляд «сверху»;
детали реализации — в дизайн-документах слоёв.
---
## Полная схема потоков данных (Data Lineage)
```mermaid
graph LR
%% Стили
classDef source fill:#e1f5fe,stroke:#01579b,stroke-width:2px;
classDef stg fill:#fff9c4,stroke:#fbc02d,stroke-width:2px;
classDef ods fill:#e0f2f1,stroke:#00695c,stroke-width:2px;
classDef dim fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px;
classDef fact fill:#ffccbc,stroke:#bf360c,stroke-width:4px;
classDef dm fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
%% 1. Source
subgraph Source_Postgres [Source: Postgres Bookings]
direction TB
SRC_Airports[airports_data]:::source
SRC_Airplanes[airplanes_data]:::source
SRC_Routes[routes]:::source
SRC_Seats[seats]:::source
SRC_Bookings[bookings]:::source
SRC_Tickets[tickets]:::source
SRC_Flights[flights]:::source
SRC_Segments[segments]:::source
SRC_Boarding[boarding_passes]:::source
end
%% 2. STAGING (Load 1-to-1, AO-Row)
subgraph STG_Layer [Layer: STG Staging]
direction TB
STG_Airports[stg.airports]:::stg
STG_Airplanes[stg.airplanes]:::stg
STG_Routes[stg.routes]:::stg
STG_Seats[stg.seats]:::stg
STG_Bookings[stg.bookings]:::stg
STG_Tickets[stg.tickets]:::stg
STG_Flights[stg.flights]:::stg
STG_Segments[stg.segments]:::stg
STG_Boarding[stg.boarding_passes]:::stg
end
%% Links Source to STG
SRC_Airports --> STG_Airports
SRC_Airplanes --> STG_Airplanes
SRC_Routes --> STG_Routes
SRC_Seats --> STG_Seats
SRC_Bookings --> STG_Bookings
SRC_Tickets --> STG_Tickets
SRC_Flights --> STG_Flights
SRC_Segments --> STG_Segments
SRC_Boarding --> STG_Boarding
%% 3. ODS (3NF, Clean, Type)
subgraph ODS_Layer [Layer: ODS Operational Core]
direction TB
ODS_Airports[ods.airports]:::ods
ODS_Airplanes[ods.airplanes]:::ods
ODS_Routes[ods.routes]:::ods
ODS_Seats[ods.seats]:::ods
ODS_Bookings[ods.bookings]:::ods
ODS_Tickets[ods.tickets]:::ods
ODS_Flights[ods.flights]:::ods
ODS_Segments[ods.segments]:::ods
ODS_Boarding[ods.boarding_passes]:::ods
end
%% Links STG to ODS
STG_Airports --> ODS_Airports
STG_Airplanes --> ODS_Airplanes
STG_Routes --> ODS_Routes
STG_Seats --> ODS_Seats
STG_Bookings --> ODS_Bookings
STG_Tickets --> ODS_Tickets
STG_Flights --> ODS_Flights
STG_Segments --> ODS_Segments
STG_Boarding --> ODS_Boarding
%% 4. DDS (Star Schema)
subgraph DDS_Layer [Layer: DDS Star Schema]
direction TB
DIM_Calendar[dds.dim_calendar]:::dim
DIM_Airports[dds.dim_airports]:::dim
DIM_Airplanes[dds.dim_airplanes]:::dim
DIM_Tariffs[dds.dim_tariffs]:::dim
DIM_Passengers[dds.dim_passengers]:::dim
DIM_Routes[dds.dim_routes SCD2]:::dim
FACT_Sales[dds.fact_flight_sales]:::fact
end
%% ODS to DDS Dimensions
ODS_Airports --> DIM_Airports
ODS_Airplanes --> DIM_Airplanes
ODS_Seats -.->|total_seats| DIM_Airplanes
ODS_Segments -.->|DISTINCT| DIM_Tariffs
ODS_Tickets -->|Unique passengers| DIM_Passengers
ODS_Routes -->|SCD2 hashdiff| DIM_Routes
DIM_Airports -.->|cities| DIM_Routes
DIM_Airplanes -.->|model, seats| DIM_Routes
%% ODS to Fact
ODS_Segments -->|Main stream| FACT_Sales
ODS_Tickets -->|book_ref, passenger_id| FACT_Sales
ODS_Bookings -->|book_date| FACT_Sales
ODS_Flights -->|schedule, route_no| FACT_Sales
ODS_Routes -->|dep/arr airports| FACT_Sales
ODS_Boarding -->|LEFT JOIN seat_no| FACT_Sales
%% Dimensions to Fact
DIM_Calendar -->|calendar_sk| FACT_Sales
DIM_Airports -->|dep/arr _sk| FACT_Sales
DIM_Airplanes -->|airplane_sk| FACT_Sales
DIM_Tariffs -->|tariff_sk| FACT_Sales
DIM_Passengers -->|passenger_sk| FACT_Sales
DIM_Routes -->|route_sk| FACT_Sales
%% 5. DM (Vitrines)
subgraph DM_Layer [Layer: DM Data Marts]
direction TB
DM_Sales[dm.sales_report]:::dm
DM_Traffic[dm.airport_traffic]:::dm
DM_Route[dm.route_performance]:::dm
DM_Monthly[dm.monthly_overview]:::dm
DM_Loyalty[dm.passenger_loyalty]:::dm
end
%% DDS to DM
FACT_Sales --> DM_Sales
FACT_Sales --> DM_Traffic
FACT_Sales --> DM_Route
FACT_Sales --> DM_Monthly
FACT_Sales --> DM_Loyalty
DIM_Airports -.-> DM_Sales
DIM_Airports -.-> DM_Traffic
DIM_Tariffs -.-> DM_Sales
DIM_Tariffs -.-> DM_Loyalty
DIM_Calendar -.-> DM_Sales
DIM_Calendar -.-> DM_Traffic
DIM_Calendar -.-> DM_Route
DIM_Calendar -.-> DM_Monthly
DIM_Calendar -.-> DM_Loyalty
DIM_Routes -.-> DM_Route
DIM_Routes -.-> DM_Monthly
DIM_Routes -.-> DM_Loyalty
DIM_Airplanes -.-> DM_Monthly
DIM_Passengers -.-> DM_Loyalty
```
---
## Сводка объектов по слоям
### STG (Staging)
Сырые данные из источника, все бизнес-поля как TEXT. Хранение: AO Row (zstd).
Служебные поля: `event_ts`, `_load_ts`, `_load_id`.
| Таблица | Источник (PXF) | Ключ | Стратегия загрузки | Distribution |
|---------|---------------|------|-------------------|--------------|
| `stg.bookings` | `bookings.bookings` | `book_ref` | Инкремент (book_date) | `book_ref` |
| `stg.tickets` | `bookings.tickets` | `ticket_no` | Инкремент (через bookings) | `book_ref` |
| `stg.flights` | `bookings.flights` | `flight_id` | Инкремент (scheduled_departure) | `flight_id` |
| `stg.segments` | `bookings.segments` | `(ticket_no, flight_id)` | Инкремент (через tickets) | `ticket_no` |
| `stg.boarding_passes` | `bookings.boarding_passes` | `(ticket_no, flight_id)` | Инкремент (через tickets/bookings) | `ticket_no` |
| `stg.airports` | `bookings.airports_data` | `airport_code` | Full snapshot | `airport_code` |
| `stg.airplanes` | `bookings.airplanes_data` | `airplane_code` | Full snapshot | `airplane_code` |
| `stg.routes` | `bookings.routes` | `(route_no, validity)` | Full snapshot | `route_no` |
| `stg.seats` | `bookings.seats` | `(airplane_code, seat_no)` | Full snapshot | `airplane_code` |
Детали: [`bookings_stg_design.md`](bookings_stg_design.md)
### ODS (Operational Data Store)
Очищенные данные с корректными типами. Справочники: AO Row (TRUNCATE+INSERT).
Транзакции: Heap (SCD1 UPSERT). Служебные поля: `_load_id`, `_load_ts`,
`event_ts` (для транзакционных таблиц).
| Таблица | Стратегия | Storage | Ключевые преобразования |
|---------|-----------|---------|------------------------|
| `ods.bookings` | SCD1 UPSERT (HWM) | Heap | `total_amount` TEXT → NUMERIC |
| `ods.tickets` | SCD1 UPSERT (HWM) | Heap | `outbound` TEXT → BOOLEAN |
| `ods.flights` | SCD1 UPSERT (HWM) | Heap | TEXT → INTEGER, TIMESTAMP WITH TIME ZONE |
| `ods.segments` | SCD1 UPSERT (HWM) | Heap | `price` TEXT → `amount` NUMERIC |
| `ods.boarding_passes` | SCD1 UPSERT (HWM) | Heap | `boarding_no` TEXT → INTEGER |
| `ods.airports` | TRUNCATE+INSERT | AO Row | JSON → отдельные поля (`airport_name`, `city`) |
| `ods.airplanes` | TRUNCATE+INSERT | AO Row | `range`/`speed` TEXT → INTEGER |
| `ods.routes` | TRUNCATE+INSERT | AO Row | `days_of_week` → INTEGER[], `scheduled_time` → TIME |
| `ods.seats` | TRUNCATE+INSERT | AO Row | Без преобразований |
Детали: [`bookings_ods_design.md`](bookings_ods_design.md)
### DDS (Detailed Data Store — Star Schema)
Измерения с суррогатными ключами. Факт в центре звезды.
SK генерация: `MAX(sk) + ROW_NUMBER()` (безопасно при `concurrency=1`).
#### Измерения
| Измерение | BK | SK | SCD | Storage | Источник |
|-----------|----|----|-----|---------|----------|
| `dim_calendar` | `date_actual` | `calendar_sk` | Static | AO Row | Генерация (2016–2030) |
| `dim_airports` | `airport_bk` | `airport_sk` | SCD1 | Heap | `ods.airports` |
| `dim_airplanes` | `airplane_bk` | `airplane_sk` | SCD1 | Heap | `ods.airplanes` + `ods.seats` (total_seats) |
| `dim_tariffs` | `fare_conditions` | `tariff_sk` | SCD1 | AO Row | `ods.segments` (DISTINCT) |
| `dim_passengers` | `passenger_id` | `passenger_sk` | SCD1 | Heap | `ods.tickets` (дедупликация) |
| `dim_routes` | `route_bk` | `route_sk` | **SCD2** | Heap | `ods.routes` + `dim_airports` + `dim_airplanes` |
#### Факт
`dds.fact_flight_sales` — зерно: 1 строка = 1 сегмент билета (`ticket_no` + `flight_id`).
| FK | Lookup-путь | Примечание |
|----|-------------|------------|
| `calendar_sk` | `dim_calendar` | Дата вылета |
| `departure_airport_sk` | `ods.routes` → `dim_airports` | Аэропорт вылета (эталонный справочник) |
| `arrival_airport_sk` | `ods.routes` → `dim_airports` | Аэропорт прилёта (эталонный справочник) |
| `airplane_sk` | `dim_routes` → `dim_airplanes` | Самолёт (point-in-time SCD2) |
| `tariff_sk` | `dim_tariffs` | Тариф |
| `passenger_sk` | `dim_passengers` | Пассажир |
| `route_sk` | `dim_routes` | Версия маршрута (SCD2, point-in-time) |
Метрики: `price` (NUMERIC), `is_boarded` (BOOLEAN).
Degenerate keys: `book_ref`, `ticket_no`, `flight_id`, `book_date`, `seat_no`.
Детали: [`bookings_dds_design.md`](bookings_dds_design.md)
### DM (Data Marts — Витрины)
Аналитические витрины поверх DDS. Каждая отвечает на конкретный бизнес-вопрос.
| Витрина | Бизнес-вопрос | Зерно | Стратегия | Storage |
|---------|--------------|-------|-----------|---------|
| `dm.sales_report` | Выручка и boarding rate по направлениям/тарифам/дням | (flight_date, dep_sk, arr_sk, tariff_sk) | UPSERT (HWM) | Heap |
| `dm.airport_traffic` | Пассажиропоток аэропортов по дням | (traffic_date, airport_sk) | UPSERT (HWM) | Heap |
| `dm.route_performance` | Эффективность маршрутов за всё время | route_bk | Full Rebuild | AO Column (zstd) |
| `dm.monthly_overview` | Помесячная динамика по типам самолётов | (year, month, airplane_sk) | UPSERT (HWM) | Heap |
| `dm.passenger_loyalty` | Профиль лояльности пассажиров | passenger_sk | UPSERT (затронутые ключи) | Heap |
Детали: [`bookings_dm_design.md`](bookings_dm_design.md)
---
## Ключевые договорённости
- **Нейминг полей**: [`naming_conventions.md`](naming_conventions.md)
- **DQ-проверки**: SQL-скрипты с `RAISE EXCEPTION` (не отдельный DQ-слой).
На ветке `main` студенческие DQ-скрипты (`airplanes_dq.sql`, `seats_dq.sql`,
student dims/DM) — заглушки (`SELECT 1;`) без проверок.
- **Инкремент STG**: для `tickets` опорная дата — из `bookings.book_date`
- **Point-in-time JOIN**: факт ↔ `dim_routes` по `[valid_from, valid_to)`
- **Суррогатные ключи**: `MAX(sk) + ROW_NUMBER()` (не SERIAL — GP-специфика)
---
## Обучающие материалы
### Глоссарий
| Термин | Объяснение |
|--------|-----------|
| **Зерно факта (Fact Grain)** | Минимальная единица в факте. Здесь — один сегмент билета. |
| **Суррогатный ключ (SK)** | Технический INT-ключ, генерируемый в DWH. |
| **Бизнес-ключ (BK)** | Ключ из источника (`airport_code`, `passenger_id`). |
| **Star Schema** | Факт в центре, измерения вокруг (без snowflake-подтаблиц). |
| **SCD Type 1** | Перезапись атрибутов без истории. |
| **SCD Type 2** | Версионирование: `valid_from`/`valid_to`, `hashdiff`. |
| **AO Row/Column** | Append-Only хранение (Row или Column). Не поддерживает UPDATE. |
| **Heap** | Стандартное хранение с поддержкой UPDATE/DELETE. |
| **HWM (High Water Mark)** | Отсечка по `MAX(_load_ts)` для инкрементальной загрузки. |
### На что обратить внимание
**Обогащение измерений:**
`seats` + `airplanes` → `dim_airplanes` — пример обогащения (total_seats).
**«Майнинг» измерений из транзакций:**
`tickets` → `dim_passengers` — в источнике нет таблицы «Пассажиры». Извлекаем
уникальных пассажиров из билетов с дедупликацией.
**Нормализация:**
`segments.fare_conditions` → `dim_tariffs` — выносим строковый атрибут в отдельный
справочник для компактного INT-ключа в факте.
**Почему нет `dim_bookings`:**
`bookings` — транзакция, не справочник. `book_ref` и `book_date` хранятся
как degenerate keys в факте.
**Point-in-time JOIN (SCD2):**
Факт присоединяется к той версии маршрута, которая действовала на дату вылета:
`scheduled_departure::DATE >= valid_from AND (valid_to IS NULL OR ... < valid_to)`.
---
## Связанные документы
- [`bookings_stg_design.md`](bookings_stg_design.md) — дизайн STG
- [`bookings_ods_design.md`](bookings_ods_design.md) — дизайн ODS (SCD1, batch contract, DQ)
- [`bookings_dds_design.md`](bookings_dds_design.md) — дизайн DDS (Star Schema, SCD2)
- [`bookings_dm_design.md`](bookings_dm_design.md) — дизайн DM (5 витрин)
- [`naming_conventions.md`](naming_conventions.md) — нейминг полей
- [`../reference/bookings_tz.md`](../reference/bookings_tz.md) — часовые пояса
- [`../reference/pxf_bookings.md`](../reference/pxf_bookings.md) — настройка PXF
- [`../assignment/analyst_spec.md`](../assignment/analyst_spec.md) — ТЗ от аналитика (курсовое задание)
+197
View File
@@ -0,0 +1,197 @@
# Развитие лабораторных работ
Рабочая заметка для автора программы, 22 сентября 2026 года.
Документ хранится в ветке `solution`; оценка программы и ссылки
на действующие задания и код относятся к учебной ветке `main`.
Приоритет — закрыть пробелы обязательного цикла. Принятое решение о новой
лабораторной записано отдельно от предложений, которые еще обсуждаются.
Действующий маршрут и требования находятся в [учебных заданиях](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/docs/assignment/README.md)
и [ТЗ](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/docs/assignment/analyst_spec.md).
## Аудитория и цель
Студент хорошо изучил PostgreSQL и прошел 16-часовой курс Greenplum от Яндекса.
Теперь ему нужна практика применения этих знаний в работе Data Engineer.
Стенд служит учебным пособием, похожим на небольшое хранилище компании:
здесь есть источники, связанные слои данных, загрузки и потребители.
Программа должна дать опыт подключения источника, построения загрузок
и моделей, оркестрации, проверки результата и восстановления после ошибок.
Знакомые SQL-инструменты студент применяет к инженерным задачам и объясняет
свои решения на данных. Разнообразие работ определяется новыми действиями
и решениями; дополнительная витрина полезна, если она дает такой опыт.
Подготовка по Airflow пока не задана. Первые необходимые действия в UI
нужно сопровождать короткой инструкцией или ссылкой на нее. Самостоятельное
написание задач и зависимостей вводится явно в соответствующей работе.
## Метод и рост самостоятельности
Стенд учебный: его можно ломать и восстанавливать. Готовая реализация
позволяет сначала увидеть работающую цепочку, а затем самостоятельно
реализовать выбранный участок. Полнота эталона при первом запуске
не ограничивает состав лабораторных.
Этот прием уже используется в
[программе Clickstream](https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/specs/2026-09-01-labs-program.md):
при «вычитании» студент удаляет готовую реализацию и пишет свою;
при работе «собери рядом» сравнивает свою реализацию с работающей;
при расследовании находит и устраняет причину подготовленной ошибки.
Можно также расширять стенд новым источником или объектом по ТЗ.
Границу самостоятельной работы определяет навык: в задании по SQL можно
оставить готовый DAG, а в задании по оркестрации — готовые SQL-скрипты.
Вычитание дает способ выделить участок для работы; самостоятельность
обеспечивают решения, которые студент принимает и проверяет:
1. В первых работах даны требования к результату, близкий пример и готовая
проверка. Студент выбирает реализацию и испытывает ее на заданных случаях.
2. Дальше студент обосновывает способ загрузки или устройство таблицы,
пишет собственные проверки и показывает, какие ошибки они обнаруживают.
3. В работах на диагностику даны симптом и критерий восстановления.
Студент сам находит причину, выбирает исправление и подтверждает результат.
Так помощь постепенно сокращается, а ответственность растет.
Заранее оставлять заглушки во всех учебных участках не требуется.
В частности, зависимость ODS от общего STG-батча объясняет
[готовый STG в текущей программе](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/docs/assignment/analyst_spec.md#почему-stg-слой-уже-реализован),
но позволяет временно разобрать его в отдельной работе.
## Принято: прием справочника входит в обязательную программу
Добавить лабораторную **«Восстановите прием справочника из источника»**.
Она закрывает практику подключения через PXF и загрузки полного снимка
в STG, которые сейчас даны готовыми.
Предлагаемый объем: восстановить внешнюю таблицу для существующего
справочника Postgres, написать его загрузку в STG и проверить передачу
полей, ключей и меток батча. Студент должен различить повтор загрузки
в том же батче и получение нового снимка. Результат должен снова
участвовать в действующей цепочке ODS → DDS → DM.
Конкретный справочник, состав удаляемых файлов, место в маршруте и проверки
еще предстоит выбрать. Кандидат — `airports`: рядом есть
[внешняя и внутренняя таблицы](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/sql/stg/airports_ddl.sql),
[загрузка снимка](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/sql/stg/airports_load.sql) и
[преобразование в ODS](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/sql/ods/airports_load.sql).
## Покрытие обязательного цикла
Оценка основана на текущем ТЗ и коде. Наличие готового примера или главы
для чтения учитывается отдельно от самостоятельной реализации.
| Участок | Что студент делает сейчас | Что стоит добавить |
|---|---|---|
| PXF и снимок STG | Запускает готовую цепочку | Принятая лабораторная по приему справочника |
| Инкремент STG | Использует готовый отбор по дате источника | Самостоятельную загрузку новых данных и проверку границы отбора |
| Снимки ODS | Реализует `airplanes`, `seats` и их DQ | Сохранить задания; использовать их после знакомства с приемом данных |
| Инкремент ODS | Использует готовые загрузки | Обработку новых ключей и изменений уже полученных строк |
| Измерения DDS | Реализует SCD1 и SCD2 | Сохранить существующий блок |
| Факт DDS | Читает пример и запускает готовую сборку | Самостоятельную сборку факта с ключами измерений |
| Витрины DM | Реализует четыре витрины с разными способами пересчета | Сохранить существующий блок; сначала закрыть пробелы ниже DM |
| DDL и физическая организация | Получает готовые таблицы, типы и ключи распределения | Написать DDL одного объекта и проверить выбор распределения опытом; предложение раскрыто ниже |
| Airflow | Запускает готовые DAG; задачи уже подключены к SQL | Самостоятельное включение загрузок и проверок в DAG |
| DQ и повторяемость | Пишет `*_dq.sql`; для ODS проверяет повтор; пользуется `bookings_validate` | Обязательные опыты на обнаружение ошибки, идемпотентность и восстановление после сбоя |
## Предложения по оставшимся пробелам
Следующие изменения пока не утверждены как обязательные лабораторные.
1. **Загрузите новые и измененные данные: STG → ODS.** Кандидат —
`bookings`: [прием в STG](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/sql/stg/bookings_load.sql) и
[обновление ODS](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/sql/ods/bookings_load.sql). Проверить первую
загрузку, следующую пачку, повтор и отсутствие новых данных.
Для изменения существующего ключа подготовить новую версию в STG:
текущий отбор источника по `book_date` сам по себе не подхватывает
исправления старых бронирований. Различие времени события и времени
загрузки — часть учебного результата. Разделение на две работы зависит
от объема самостоятельного SQL.
2. **Соберите факт продаж.** После измерений восстановить
[загрузку факта](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/sql/dds/fact_flight_sales_load.sql): сохранить
зерно `(ticket_no, flight_id)`, подобрать ключи измерений и версию
маршрута на дату рейса, обработать обновления и повторный запуск.
Проверить покрытие ключей, значения и отсутствие размножения строк.
Простого совпадения количества строк недостаточно.
3. **Организуйте загрузку в Airflow и восстановите ее после сбоя.**
Использовать уже написанный SQL, самостоятельно связать задачи загрузки
и DQ. На подготовленном входе вызвать сбой, определить состояние данных
и завершить обработку повторным запуском. Генератор управляется отдельно:
его повтор продвигает источник на новый день и меняет условия опыта.
4. **Проверьте физическую организацию таблицы в Greenplum.** Предлагается
включить в обязательный цикл компактный опыт: написать DDL, сравнить
два варианта распределения одних данных, посмотреть строки по сегментам
и планы одного запроса, обосновать выбор. Результат запроса должен
совпадать в обоих вариантах. Наличие нескольких сегментов и достаточный
для наблюдения объем нужно проверить при подготовке работы. Место опыта
еще не выбрано; кандидат — задание по факту. Подробную оптимизацию
можно развить в факультативах.
## Предлагаемые переходы между работами
Порядок обсуждения новых работ: прием справочника → инкремент STG/ODS →
сборка факта → оркестрация и восстановление. Между ними остаются
существующие задания на снимки ODS, измерения и витрины. Точное место
новых работ, включая опыт с распределением, еще обсуждается.
Для встраивания в маршрут предлагаются следующие границы:
| Новая работа | Состояние перед началом | Результат для продолжения |
|---|---|---|
| Прием справочника | Пройден быстрый старт, работает эталонная цепочка до `sales_report`; выбран участок для вычитания | Собственная загрузка получает новый снимок STG, который участвует в общем батче справочников и принимается ODS |
| Инкремент STG/ODS | Разобраны прием снимка и существующие задания ODS; подготовлены таблицы и входные пачки | Собственные загрузки дают ожидаемое текущее состояние ODS после всех проверочных случаев |
| Сборка факта | Реализованы и проверены измерения DDS | Факт построен собственным SQL, проверены ключи и значения; на нем можно выполнять задания DM |
| Оркестрация и восстановление | SQL выбранного участка реализован и проверен | DAG выполняет загрузку и DQ в нужном порядке; после подготовленного сбоя обработка завершается с ожидаемыми данными |
Готовность оценивается по нужному этапу: на исходной `main` часть таблиц
еще является заданием, поэтому проход всех проверок будущих слоев
не может быть условием начала первой работы. Действующие особенности
запусков и состояния слоев описаны в [порядке выполнения DAG](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/main/docs/dag_execution_order.md).
Предложения основаны на чтении исходников; воспроизводимость новых
сценариев на стенде еще не проверялась.
## Как подготовить лабораторную
Эти критерии общие для обязательных работ и факультативов. Перед написанием
инструкции определите:
- **Навык:** рабочая ситуация, новое действие или решение DE и его отличие
от уже пройденных работ.
- **Вход:** необходимые задания, состояние таблиц и загрузок, подготовка
данных и способ воспроизвести ситуацию.
- **Самостоятельная часть:** что студент получает готовым, что пишет
или меняет сам и какие решения должен обосновать.
- **Результат:** что будет работать после задания и по каким наблюдениям
студент сможет это подтвердить.
- **Проверочные случаи:** конкретные данные или события, ожидаемый результат
каждого случая и способ обнаружить неверное решение.
- **Выход:** что сохраняется для следующей работы и как получить нужное
состояние стенда после опыта или перерыва.
Для инкремента получение новых строк из источника и применение уже
полученных изменений в ODS проверяются отдельно. Для собственной DQ-проверки
нужны отказ на известной ошибке и успех после исправления. Сверка с готовой
реализацией подтверждает совпадение поведения; проверки по источнику
подтверждают полноту и бизнес-правила.
Полная пересборка стенда допустима. Адресное восстановление требуется там,
где оно само является предметом работы. При пересборке инструкция должна
объяснять, как снова применить сохраненные файлы решения и получить
состояние для продолжения.
Лабораторная готова к выдаче, когда все ее проверочные случаи пройдены
на стенде из описанного начального состояния, проверки обнаружили
предусмотренные ошибки, а заявленный выход позволяет продолжить маршрут.
Команды и результаты проверки сохраняются вместе с изменением задания.
Текстовая вычитка и проверка ссылок подтверждают только качество документа.
## Основа для будущих факультативов
Факультативы расширяют опыт за пределами обязательного набора. При генерации
кандидатов используйте общие критерии выше и указывайте, после каких
обязательных работ доступен каждый сюжет.
Направления для последующей генерации: изменения схемы источника,
опоздавшие данные, история и пересчеты, углубленная оптимизация запросов
Greenplum, свежесть данных, разграничение доступа, новые способы приема.
Это исходные темы для отбора, а не утвержденный список заданий.
+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`.
+184
View File
@@ -0,0 +1,184 @@
# Протокол сквозного (E2E) тестирования ETL
Этот документ описывает процедуру полной проверки цепочки ETL: `STG -> ODS -> DDS -> DM`.
Цель теста — убедиться в корректности инкрементальной загрузки, работы паттерна `Temporary Table` и механизмов `HWM`.
**Управление DAG'ами — только через Airflow UI или REST API.**
Не используйте CLI-команды внутри контейнера (`docker exec ... airflow dags ...`) — они могут молча не сработать (например, `unpause` не снимает паузу). REST API работает надёжно и приближает опыт к реальной боевой эксплуатации.
---
## 1. Подготовка окружения
Убедитесь, что все сервисы запущены и генератор инициализирован.
```bash
make up
make bookings-init
```
**Доступ к API:**
В `docker-compose.yml` включена базовая аутентификация (`basic_auth`).
Для curl-запросов используйте учетные данные из вашего `.env` файла (переменные `AIRFLOW_USER` и `AIRFLOW_PASSWORD`, по умолчанию `admin:admin`).
- **UI:** `http://localhost:8080`
- **API Endpoint:** `http://localhost:8080/api/v1`
---
## 2. Очистка данных (Reset)
Перед началом теста необходимо полностью очистить все слои DWH.
```bash
make dwh-truncate
```
*(Если вы меняли DDL, лучше полностью пересоздать схемы: `make gp-psql -c "DROP SCHEMA IF EXISTS ods CASCADE; DROP SCHEMA IF EXISTS dds CASCADE; DROP SCHEMA IF EXISTS dm CASCADE; CREATE SCHEMA ods; CREATE SCHEMA dds; CREATE SCHEMA dm;"`)*
---
## 3. Этап 1: Создание схем и Загрузка за первый день (Initial Load)
Рекомендуется запускать слои последовательно, дожидаясь завершения предыдущего.
### Создание DDL
Откройте **Airflow UI** (`http://localhost:8080`) и нажмите кнопку **▶ Play -> Trigger DAG** для DDL-дагов:
1. `bookings_stg_ddl`
2. `bookings_ods_ddl`
3. `bookings_dds_ddl`
4. `bookings_dm_ddl`
### Запуск пайплайна (Day 1)
После успешного создания таблиц, запустите DAG загрузки `bookings_to_gp_stage`.
**Через UI:** откройте http://localhost:8080, **снимите DAG с паузы** (переключатель слева от названия) и нажмите **▶ Play -> Trigger DAG**.
**Через REST API (рекомендуется для автоматизации и агентов):**
**Важно:** при старте стенда все DAG-и находятся на паузе. Каждый DAG перед первым запуском нужно «разморозить» (unpause), иначе запуск зависнет в статусе `queued`. Для каждого DAG требуется два шага: **1) unpause, 2) trigger**.
```bash
# 1. Снятие с паузы (обязательно перед первым запуском!)
curl -s -X PATCH "http://localhost:8080/api/v1/dags/bookings_to_gp_stage" \
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
-H "Content-Type: application/json" \
-d '{"is_paused": false}'
# 2. Запуск
curl -s -X POST "http://localhost:8080/api/v1/dags/bookings_to_gp_stage/dagRuns" \
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
-H "Content-Type: application/json" \
-d '{}'
```
### Проверка статуса
Дождитесь, пока DAG перейдет в статус `success` (следите в UI или опрашивайте через API):
```bash
curl -s "http://localhost:8080/api/v1/dags/bookings_to_gp_stage/dagRuns?order_by=-execution_date&limit=1" \
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" | python3 -c "import sys,json; print(json.load(sys.stdin)['dag_runs'][0]['state'])"
```
### Запуск остальных слоёв
Поочерёдно запускайте каждый следующий слой **по той же схеме: unpause + trigger** (замените `<dag_id>`):
1. `bookings_to_gp_ods`
2. `bookings_to_gp_dds`
3. `bookings_to_gp_dm`
```bash
# Повторите для каждого DAG из списка выше
DAG_ID=bookings_to_gp_ods
# 1. Unpause
curl -s -X PATCH "http://localhost:8080/api/v1/dags/${DAG_ID}" \
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
-H "Content-Type: application/json" \
-d '{"is_paused": false}'
# 2. Trigger
curl -s -X POST "http://localhost:8080/api/v1/dags/${DAG_ID}/dagRuns" \
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
-H "Content-Type: application/json" \
-d '{}'
```
---
## 4. Этап 2: Проверка инкремента
Эмулируйте появление данных за второй день и проверьте дозагрузку. DAG слоя STG автоматически сгенерирует новый день в базе-источнике перед загрузкой.
```bash
# Повторный запуск цепочки ETL (DAG STG сам сгенерирует новый день)
# Снова нажмите "Trigger DAG" в UI для каждого слоя (STG -> ODS -> DDS -> DM).
```
---
## 5. Цикл отладки: Логи и Перезапуск (Clear)
Если DAG упал, **не нужно пересоздавать стенд с нуля**. Airflow позволяет исправить код и перезапустить только упавшие задачи.
Для выполнения команд ниже вам понадобится **Run ID** упавшего запуска. Его можно скопировать из UI (вкладка *Graph* -> кликнуть на фон сетки -> вкладка *Details* -> `Run ID`) или получить последним API-запросом:
```bash
# Получить Run ID последнего запуска ODS
curl -s "http://localhost:8080/api/v1/dags/bookings_to_gp_ods/dagRuns?order_by=-execution_date&limit=1" \
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" | grep -o '"dag_run_id": "[^"]*"'
```
### Чтение логов через API
Подставьте ваш `<RUN_ID>` (например, `manual__2026-03-03T...`) и имя упавшей таски:
```bash
curl -s "http://localhost:8080/api/v1/dags/bookings_to_gp_ods/dagRuns/<RUN_ID>/taskInstances/<TASK_ID>/logs/1" \
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}"
```
### Перезапуск задачи (Clear)
1. Прочитайте ошибку в логах.
2. Исправьте SQL-файл локально на хосте.
3. Очистите состояние упавших задач (`only_failed: true`) в конкретном запуске, передав ваш `<RUN_ID>`:
```bash
curl -s -X POST "http://localhost:8080/api/v1/dags/bookings_to_gp_ods/clearTaskInstances" \
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
-H "Content-Type: application/json" \
-d '{
"only_failed": true,
"reset_dag_runs": true,
"dag_run_id": "<RUN_ID>"
}'
```
После этого планировщик подхватит обновленный SQL-код и продолжит выполнение DAG с точки падения. Вы также можете сделать это в UI: клик по упавшей задаче -> кнопка **Clear**.
---
## 6. Финальная верификация (Критерии успеха)
Выполните SQL-запрос для сверки данных:
```bash
make gp-psql -c "
SELECT 'STG' as layer, COUNT(*) FROM stg.bookings
UNION ALL
SELECT 'ODS' as layer, COUNT(*) FROM ods.bookings
UNION ALL
SELECT 'DDS' as layer, COUNT(*) FROM dds.fact_flight_sales
UNION ALL
SELECT 'DM ' as layer, COUNT(*) FROM dm.sales_report;
"
```
**Критерии корректности:**
1. **Инкремент STG**: Количество строк в `stg.bookings` после Этапа 2 больше, чем после Этапа 1.
2. **Инкремент ODS**: Количество строк в `ods.bookings` выросло. В ODS нет дублей (`SELECT book_ref FROM ods.bookings GROUP BY book_ref HAVING COUNT(*) > 1` должен вернуть 0 строк).
3. **ODS Справочники**: Количество строк в `ods.airports` и `ods.routes` не должно меняться между днями (работает паттерн TRUNCATE+INSERT полного снимка).
4. **HWM в DM**: Витрина `sales_report` содержит данные за оба дня. Значение `COUNT(*)` после Этапа 2 выросло.
5. **Lineage**: Поля `_load_id` и `_load_ts` во всех слоях содержат метки соответствующих запусков (`manual__...`).
---
## 7. Зафиксированный опыт (Типичные ошибки)
- **Рассинхронизация DDL и Load скриптов**: Частая причина падения ODS/DDS — несовпадение имен колонок (например, `amount` vs `segment_amount`) или типов данных (например, `INTEGER[]` vs `TEXT`) между схемой таблицы и запросом загрузки. Внимательно читайте логи задачи.
- **Работа с массивами**: При генерации `hashdiff` в Greenplum/PostgreSQL нельзя использовать пустую строку `''` в `COALESCE` для массива. Массив нужно предварительно привести к тексту: `COALESCE(days_of_week::TEXT, '')`.
- **Кавычки в psql**: При выполнении ручных проверок через `psql -c "..."` помните, что строковые литералы должны оборачиваться в **одинарные кавычки** (`'text'`), а двойные кавычки (`"text"`) интерпретируются как идентификаторы колонок.
@@ -0,0 +1,342 @@
# Plan: Подготовка main + ветка solution (Этап 4)
> Дата: 2026-03-12 | Версия: 4 (после третьего ревью Codex)
> Ветка: chore/bookings-etl → main → solution
> Спецификации кода: `docs/archive/2026-03-11_routes-to-reference.md` (п.2–5, п.8, п.10–11)
---
## 1. Мотивация
### Зачем раскладка по веткам
Сейчас весь код (эталон + студенческие задания) живёт в ветке `chore/bookings-etl`.
Студент должен получить **частично реализованный пайплайн**: эталонный вертикальный
срез работает «из коробки», а студенческие файлы — заглушки (`SELECT 1; -- TODO`).
Полная реализация доступна в ветке `solution` для самопроверки.
### Почему именно такой порядок
**Рассмотренные варианты:**
| # | Схема | Проблема |
|---|-------|----------|
| A | Создать solution от bookings-etl, потом на main — cherry-pick + заглушки | Грязные cherry-pick'и, main временно «не main» |
| B | Мерж в main, заглушки на main, solution = cherry-pick эталона обратно | main в какой-то момент содержит полный код, потом — заглушки; solution — через cherry-pick |
| **C** | **Мерж в main → общие правки → ветка solution (снимок) → main-only правки** | **Выбран** |
**Почему вариант C:**
- **main всегда остаётся main.** Нет момента, когда main «подменяется» другой веткой.
Любые CI/CD, ссылки, клонирования — работают непрерывно.
- **Простая линейная история.** Мерж → общие правки → бранч → main-only коммиты.
Никаких cherry-pick'ов.
- **solution — архивный снимок.** Не ожидается активная разработка. Студент сверяется
с ним, а не мержит.
- **Общие правки до ветвления.** Документация, актуальная для обеих веток,
обновляется *до* создания solution. Solution получает её автоматически.
### Стратегия синхронизации веток
После раскладки `main` (заглушки) и `solution` (полный код) расходятся.
**Главный принцип: solution — source of truth.**
Все изменения начинаются на solution (или feature-ветке от solution),
затем портируются в main. Направление потока: **solution → main**.
Файлы делятся на три категории:
| Категория | Примеры | Правило |
|-----------|---------|---------|
| **Общий код** | STG SQL, docker, Makefile, инфраструктура | Фиксим на solution, cherry-pick в main |
| **Общая документация** | README, TESTING, stack.md, naming_conventions | Фиксим на solution, cherry-pick в main |
| **Branch-specific** | заглушки load/dq, fact_flight_sales_dq (ослабленный), routes_dq (без RI), ODS DAG (без airplanes→routes), branch-specific формулировки в design docs | Фиксим на нужной ветке. Конфликтов нет — содержимое файлов разное |
**Исключение:** main-only правка (опечатка в заглушке, битая ссылка
в main-only документе) — правим прямо на main, solution не трогаем.
**Главное правило: никогда не мержим main → solution целиком.**
> Детальный протокол синхронизации — открытый вопрос, см. секцию 3.
---
## 2. Порядок выполнения
### Шаг 1. PR chore/bookings-etl → main
- Создать PR, ревью
- Мерж (squash или обычный — на усмотрение)
- После мержа: `git checkout main && git pull`
### Шаг 2. Общие правки на main (до ветвления solution)
> **Bootstrap-исключение:** стратегия синхронизации (секция 1) определяет
> solution как source of truth с потоком solution → main. Но при первичной
> раскладке solution ещё не существует — общие правки делаются на main,
> и solution наследует их при ветвлении (шаг 3). После создания solution
> действует штатный протокол.
Эти изменения отражают код, уже изменённый на bookings-etl
(fact_flight_sales_load.sql использует ods.routes). Документация должна
соответствовать коду на **обеих** ветках.
#### 2.1. Документация (общая для обеих веток)
- `docs/design/bookings_dds_design.md` — обновить описание fact lookup
(airports через ods.routes, airplane через dim_routes point-in-time)
- `docs/bookings_to_gp_dds.md` — обновить описание DDS lookup
- `docs/bookings_to_gp_ods.md` — обновить описание ODS
- `docs/design/db_schema.md` — пометить STG + ods.routes как эталон
#### 2.2. TODO.md — переписать
Текущий текст Этапа 4 в TODO.md описывает устаревшую стратегию
(удалить routes из STG, убирать `\i` из ddl_gp.sql). Нужно обновить,
чтобы на solution осталась корректная история:
- Отметить этапы 2, 3 выполненными (✅)
- **Переписать** текст этапа 4 (не просто поставить галочку) — привести
в соответствие с фактической стратегией (данный план)
- Отметить этап 4 выполненным после завершения
### Шаг 3. Создать ветку solution
```bash
git checkout main
git checkout -b solution
git push -u origin solution
```
Solution получает: полный эталонный код + актуальную общую документацию +
все design docs, plans, archive, TODO.md — полный контекст для мейнтейнера.
### Шаг 4. Main-only правки (заглушки, ослабление DQ)
Работаем на main (или на feature-ветке → PR в main).
#### 4.1. Код: fact_flight_sales_dq.sql — ослабить student SK
Спецификация: архивный план, п.2.
- `passenger_sk IS NULL` → `RAISE NOTICE` (было `RAISE EXCEPTION`)
- Разделить route-related блок: airport_sk (порог 1%, EXCEPTION) vs
route_sk/airplane_sk (NOTICE only)
- Добавить учебный комментарий
#### 4.2. Код: routes_dq.sql — закомментировать RI airplane
Спецификация: архивный план, п.3.
- **Закомментировать** (не удалять) блок RI `airplane_code → ods.airplanes`
- Добавить комментарий:
```sql
-- Проверка RI airplane_code → ods.airplanes закомментирована,
-- т.к. таблица ods.airplanes реализуется студентом.
-- После реализации — раскомментируйте этот блок.
-- Полную версию см. в ветке solution.
```
#### 4.3. Код: ODS DAG — убрать зависимость airplanes → routes
Спецификация: архивный план, п.4.
- `[dq_ods_airports, dq_ods_airplanes] >> load_ods_routes`
→ `dq_ods_airports >> load_ods_routes`
#### 4.4. Заглушки: студенческие файлы
Спецификация: архивный план, п.5.
| Слой | Файлы (load + dq) | DDL |
|------|--------------------|-----|
| ODS | airplanes, seats | Оставить (таблица нужна) |
| DDS | dim_routes, dim_passengers, dim_airplanes | Оставить (нужен для LEFT JOIN) |
| DM | airport_traffic, route_performance, monthly_overview, passenger_loyalty | Оставить |
Формат заглушки load:
```sql
-- TODO: реализуйте загрузку (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
```
Формат заглушки dq:
```sql
-- TODO: реализуйте проверки качества данных
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
```
#### 4.5. Тесты
Спецификация: архивный план, п.10.
- `test_dags_smoke.py`: убрать assert барьера `dq_ods_airplanes → dq_ods_routes`
- `test_ods_sql_contract.py`: `SNAPSHOT_ENTITIES = ("airports", "routes")`
(убрать airplanes, seats — их load/dq теперь заглушки)
#### 4.6. Документация (main-specific)
- `docs/design/bookings_ods_design.md` — пометить airplanes/seats как студенческие,
убрать зависимость routes от airplanes, скорректировать DQ-контракт
- `docs/design/bookings_dds_design.md` — обновить DQ-описание:
student SK не блокируют (NOTICE), airport_sk — порог 1%
- `docs/bookings_to_gp_dds.md` — обновить: student SK будут NULL на main
- `docs/reference/qa-plan.md` — уточнить: ods.airplanes и ods.seats
пусты by design на main
### Шаг 5. Очистка docs на main (после всех правок)
> Этот шаг выполняется **после** шагов 2–4, потому что:
> - solution уже создан (шаг 3) и сохранил все файлы
> - план и архивный справочник были доступны во время работы (шаг 4)
#### 5.1. Удалить с main (остаётся на solution)
| Файл/каталог | Почему не нужен студенту |
|--------------|------------------------|
| `docs/plans/` (весь каталог) | Планы разработки — внутренняя кухня |
| `docs/archive/` (весь каталог) | Архив планов — внутренняя кухня |
| `docs/design/PRD.md` | Продуктовые требования — внутренний документ |
| `docs/design/assignment_design.md` | Мета-дизайн задания (для авторов курса, не для студентов) |
| `docs/reference/bookings_generation_benchmark.md` | Бенчмарки генерации — отладочная информация |
| `docs/reference/bookings_tz.md` | Заметки о таймзонах — отладочная информация |
| `docs/reference/pxf_bookings.md` | PXF-конфигурация — внутренняя отладка |
| `docs/agent-dag-testing.md` | Инструкция для AI-агентов по тестированию |
| `docs/e2e-etl-test-protocol.md` | E2E-протокол — внутреннее тестирование |
| `TODO.md` | Таск-лист мейнтейнера |
#### 5.2. Что остаётся на main (полезно студенту)
| Файл | Зачем студенту |
|------|---------------|
| `README.md` | Установка, запуск, структура проекта |
| `TESTING.md` | Как проверять свою работу |
| `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` | Если студент использует AI-помощников |
| `docs/README.md` | Навигация по документации |
| `docs/stack.md` | Стек технологий — контекст |
| `docs/dag_execution_order.md` | Порядок запуска DAG'ов |
| `docs/bookings_to_gp_stage.md` | Описание STG — эталонный код для изучения |
| `docs/bookings_to_gp_ods.md` | Описание ODS |
| `docs/bookings_to_gp_dds.md` | Описание DDS |
| `docs/bookings_to_gp_dm.md` | Описание DM |
| `docs/design/naming_conventions.md` | Нейминг полей — нужен для DDL/SQL |
| `docs/design/db_schema.md` | Схема БД — справочник |
| `docs/design/bookings_stg_design.md` | Дизайн STG — эталон для изучения |
| `docs/design/bookings_ods_design.md` | Дизайн ODS — нужен для задания |
| `docs/design/bookings_dds_design.md` | Дизайн DDS — нужен для задания |
| `docs/design/bookings_dm_design.md` | Дизайн DM — нужен для задания |
| `docs/assignment/` | Задание (analyst_spec.md) |
| `docs/reference/qa-plan.md` | QA-чеклист — полезен для самопроверки |
| `docs/reference/bookings_db_issues.md` | Известные проблемы — чтобы студент не тратил время на отладку |
#### 5.3. AGENTS.md — адаптировать для main
`AGENTS.md` содержит карту проекта и ссылки, которые побьются после cleanup.
Нужно обновить, а не просто добавить одну строку:
- **Карта проекта (секция 2):** убрать упоминания `docs/plans/`, `docs/archive/`.
Добавить: «Полные дизайн-документы (PRD, assignment_design, планы) — в ветке solution»
- **Тестирование:** убрать ссылку на `docs/agent-dag-testing.md` (файл удалён)
- **Прочие ссылки:** проверить, что все пути в AGENTS.md ведут на существующие файлы
#### 5.4. Link audit — полная проверка ссылок
Проверить **все** ссылки во **всех** оставшихся на main markdown-файлах,
а не только ссылки на удалённые файлы. В документации уже есть битые ссылки
(напр. `docs/README.md` → `design/architecture_review.md`, файл в архиве).
**Метод:**
```bash
# 1. Найти все markdown-ссылки в оставшихся файлах
rg -o '\[.*?\]\([^)]+\.md[^)]*\)' docs/ README.md TESTING.md AGENTS.md
# 2. Проверить существование каждого целевого файла
# 3. Починить: удалить ссылку, обновить путь, или заменить на «см. ветку solution»
```
Известные проблемы (минимум):
- `docs/README.md` — ссылки на plans/, archive/, PRD, assignment_design, architecture_review
- `docs/assignment/README.md` — возможные ссылки на assignment_design
- `docs/design/bookings_stg_design.md` — ссылки на внутренние reference
- `docs/bookings_to_gp_stage.md` — ссылки на reference
- `docs/stack.md` — ссылки на reference
- `docs/design/db_schema.md` — ссылки на PRD, assignment_design
- `README.md` — ссылки на TODO.md, PRD
- `AGENTS.md` — покрыто шагом 5.3
### Шаг 6. Верификация main
```bash
make test # smoke-тесты и контракты
make lint # линтинг
```
При поднятом стенде:
1. `make up` → `make ddl-gp`
2. Запустить STG → ODS → DDS → DM DAG'и
3. Проверить: `sales_report` содержит данные с корректными аэропортами
4. Проверить: студенческие ODS/DDS-таблицы пусты
5. Fact DQ проходит (student SK = NULL, но DQ не блокирует)
### Шаг 7. Верификация solution
```bash
git checkout solution
make test
```
Убедиться, что полный пайплайн работает без изменений (это снимок
проверенного chore/bookings-etl + общие doc-правки).
---
## 3. Открытый вопрос: протокол синхронизации веток
> Не блокирует Этап 4. Доработать после раскладки, когда появится конкретный
> опыт (например, при первом PXF-задании).
### Предварительное решение: solution как source of truth
Все изменения (новые задания, баг-фиксы, доработки) начинаются на solution
или на feature-ветке от solution. Main — производная.
```
solution ← feat/new-task (разработка + тесты)
↓ merge
solution (полный эталон, всегда рабочий)
↓ порт
main (cherry-pick общего + заглушки)
```
**Поток по умолчанию:** solution → main (одно направление).
**Исключение (редко):** main-only правка (опечатка в заглушке, битая ссылка
в main-only документе) — правим прямо на main, solution не трогаем.
### Что нужно доработать
- Как именно выглядит «порт в main»: cherry-pick, ручной перенос, чеклист?
- Что делать при конфликтах cherry-pick?
- Нужен ли реестр known-different файлов (заглушки vs реализации) для
автоматической проверки синхронизации?
- Нужен ли периодический `git diff main..solution -- <shared files>` для
обнаружения расхождений?
---
## 4. Ключевые файлы
Полная таблица с ролями и ветками: архивный план, секция «Ключевые файлы».
Дополнительно (docs cleanup, шаг 5.1):
| Действие | Файлы |
|----------|-------|
| Удалить с main | `docs/plans/*`, `docs/archive/*`, `docs/design/PRD.md`, `docs/design/assignment_design.md`, `docs/reference/bookings_generation_benchmark.md`, `docs/reference/bookings_tz.md`, `docs/reference/pxf_bookings.md`, `docs/agent-dag-testing.md`, `docs/e2e-etl-test-protocol.md`, `TODO.md` |
| Обновить на main | `AGENTS.md` (ссылка на solution), все .md с битыми ссылками (link audit) |
+3
View File
@@ -0,0 +1,3 @@
# Планы работ
Активные планы. После выполнения переносятся в [`archive/`](../archive/).
+143
View File
@@ -0,0 +1,143 @@
# Проблемы bookings-db (demodb)
> Дата обнаружения: 2026-03-08
> Зафиксированный коммит demodb: `866e56f7fe54596a1d2a88f5f32f4aa3b2698121`
---
## 1. gen.connstr без credentials — генерация обрывается
### Симптом
После `make bookings-generate` (генерация с нуля) таблицы в demo-базе существуют, но **пустые**.
Повторные `make bookings-generate-day` тоже дают 0 строк.
### Корневая причина
`install.sql` из demodb хардкодит:
```sql
ALTER DATABASE demo SET gen.connstr = 'dbname=demo';
```
Без `user` и `password`. Makefile устанавливает правильный connstr
(с `user=bookings password=bookings`) **после** `install.sql`, но при повторном
`make bookings-generate` порядок тот же: install.sql перезаписывает → Makefile
восстанавливает. Если что-то идёт не так между этими шагами, connstr остаётся
без credentials.
Далее цепочка:
1. `process_queue()` обрабатывает события (BOOKING, FLIGHT, VACUUM и т.д.)
2. Событие VACUUM вызывает `dblink_exec(gen.connstr, 'VACUUM ANALYZE')`
3. dblink без user → подключается как `postgres` → `FATAL: role "postgres" does not exist`
4. `process_queue()` ловит ошибку, логирует и **прекращает работу**
5. Генерация обрывается на 1-2 модельных днях → данных почти нет
### Диагностика
```sql
-- В psql к demo-базе:
SHOW gen.connstr;
-- Если "dbname=demo" без user → проблема подтверждена
SELECT * FROM gen.log ORDER BY at DESC LIMIT 5;
-- Искать: "could not establish connection" / "role postgres does not exist"
```
### Воркэраунд
```sql
-- Выполнить на bookings-db ПОСЛЕ install.sql (актуально при make bookings-generate):
ALTER DATABASE demo SET gen.connstr = 'dbname=demo user=bookings password=bookings';
-- Затем переподключиться к demo и запустить генерацию заново.
```
### Правильное решение
Один из вариантов:
- Патч `install.sql`, подставляющий credentials из ENV
- Обновление demodb до версии, где это исправлено
---
## 2. BOOKINGS_INIT_DAYS=1 — недостаточно для генерации данных
### Симптом
Даже при исправленном connstr, с `BOOKINGS_INIT_DAYS=1` (дефолт в Makefile)
`bookings.bookings` остаётся пустой.
### Корневая причина
Генератор demodb использует константы:
- `ROUTES_DURATION() = 1 month` — маршруты строятся на месяц вперёд
- `ROUTES_TAKE_EFFECT() = 2 months` — маршруты начинают действовать через 2 месяца
При `start_date=2017-01-01` и `init_days=1`:
- `end_date = 2017-01-02`
- Генератор успевает только INIT + BUILD ROUTES (маршруты на февраль-март)
- До создания бронирований не доходит → 0 строк
Проверено:
| init_days | bookings | boarding_passes | Время генерации |
|-----------|----------|-----------------|-----------------|
| 1 | 0 | 0 | ~1 сек |
| 30 | ~30 000 | 0 | ~1-2 мин |
| 90 | ? | ? (ожидаем >0) | ~10+ мин |
---
## 3. boarding_passes всегда пустая
### Симптом
Таблица `bookings.boarding_passes` ни разу не содержала данных.
### Корневая причина
Boarding passes создаются при событиях CHECK-IN и BOARDING, которые
генерируются при REGISTRATION рейса (~24ч до вылета). Рейсы начинаются
с 2017-02-01. Цепочка:
1. Нужны маршруты → появляются при init_days ≥ 1
2. Нужны рейсы → появляются при init_days ≥ 30
3. Нужны бронирования/сегменты → появляются при init_days ≥ 30
4. Нужна регистрация (за ~24ч до вылета) → init_days ≥ ~60
5. Нужны boarding events → init_days ≥ ~60
При init_days ≤ 30 генератор не доходит до дат регистрации.
При init_days ≥ 60 баг #1 (connstr) убивал генерацию раньше.
Возможно, есть и баг в самой версии demodb — требует проверки после
обновления.
---
## 4. UX: время генерации
Генерация данных занимает значительное время:
| init_days | Время | Достаточно для |
|-----------|------------|-----------------------|
| 30 | ~1-2 мин | bookings, но не bp |
| 60 | ~5 мин | предположительно bp |
| 90 | ~10+ мин | всех таблиц (?) |
Для студента ждать 10 мин при первом запуске — плохой UX.
### Альтернативы
1. **Готовый SQL-дамп** первых N дней (`pg_dump` → `pg_restore`, секунды)
2. **Docker image с предзаполненной БД** (ещё быстрее, 0 ожидания)
3. **Уменьшить масштаб** — найти параметры demodb для меньшего числа аэропортов/маршрутов
---
## Рекомендации (TODO)
- [x] Обновить demodb до последнего коммита (`866e56f`) — upstream только README-правки, баги не исправлены
- [x] Добавить патч для gen.connstr (`install_connstr_no_hardcode.patch`) — убирает хардкод без credentials
- [x] Увеличить BOOKINGS_INIT_DAYS до 60 (в Makefile и .env)
- [x] Добавить валидацию после init (count > 0 для bookings, flights; вывод counts всех таблиц)
- [ ] Решить проблему UX с временем генерации (дамп или prebuilt image)
- [ ] Проверить, появляются ли boarding_passes при init_days=60 + исправленном connstr (нужен запуск стенда)
@@ -0,0 +1,75 @@
# Бенчмарк генерации bookings: параллельность и тюнинг PostgreSQL
> Дата: 2026-03-09
> Железо: Intel Core i7-11800H @ 2.30GHz (8 ядер / 16 потоков), игровой ноутбук
> Среда: WSL2, Docker Desktop, PostgreSQL 16 в контейнере
> Данные: seed-дамп 60 дней (~500k bookings, ~180k boarding_passes, ~1.2M tickets)
## Контекст
Генератор demodb (`postgrespro/demodb`) использует очередь событий `gen.events` с обработкой через `process_queue()`. При `jobs>1` воркеры запускаются через `dblink_send_query` и конкурируют за очередь через `SELECT ... FOR UPDATE SKIP LOCKED`.
Задача: найти оптимальную конфигурацию для `make bookings-generate-day` (инкремент +1 день).
## Методика
- Между экспериментами: `make bookings-init BOOKINGS_JOBS=N` (восстановление из seed-дампа, ~18 сек).
- Замер: `time make bookings-generate-day BOOKINGS_JOBS=N`.
- Контроль: `max(book_date)` должен сдвинуться на +1 день.
- Тюнинг PG: `ALTER SYSTEM SET` + `docker compose restart bookings-db`.
Параметры тюнинга PostgreSQL:
```
shared_buffers = 512MB (дефолт: 128MB)
work_mem = 64MB (дефолт: 4MB)
maintenance_work_mem = 256MB (дефолт: 64MB)
effective_cache_size = 1GB (дефолт: 4GB)
wal_buffers = 16MB (дефолт: -1, авто)
checkpoint_completion_target = 0.9 (дефолт: 0.9)
random_page_cost = 1.1 (дефолт: 4.0)
```
## Результаты
| # | Тюнинг PG | sync_commit | Jobs | Время | vs baseline |
|---|-----------|-------------|------|--------|--------------|
| 1 | нет | on | 1 | 2m 48s | **baseline** |
| 2 | нет | on | 2 | 8m 36s | 3× хуже |
| 3 | да | on | 1 | 3m 01s | шум |
| 4 | да | on | 2 | 8m 42s | 3× хуже |
| 5 | да | off | 1 | 2m 50s | шум |
| 6 | да | off | 2 | 8m 39s | 3× хуже |
Повторный инкремент (прогретый кэш): 5m 03s (jobs=2), не замерялся (jobs=1).
## Выводы
### 1. jobs=1 оптимален для инкрементов
Для генерации +1 дня параллельность через dblink **контрпродуктивна**:
- Два воркера конкурируют за одну очередь `gen.events` через `FOR UPDATE SKIP LOCKED` — это row-level lock contention.
- Overhead: dblink-соединения, синхронизация через `gen.stat_jobs`, polling через `dblink_is_busy()`.
- На WSL2 дополнительно: виртуализированный I/O не масштабируется при параллельных записях.
При генерации с нуля (`make bookings-generate`, десятки тысяч событий) jobs>1 **может** давать прирост, но не тестировалось в этом бенчмарке.
### 2. Тюнинг PostgreSQL не влияет
Увеличение shared_buffers в 4 раза, work_mem в 16 раз и т.д. не дало измеримого эффекта. Узкое место — не буферы и не I/O, а сама логика генератора: последовательная обработка событий с COMMIT после каждого.
### 3. synchronous_commit=off не влияет
Генератор делает COMMIT после каждого события (~сотни раз за день). Ожидалось, что `synchronous_commit=off` ускорит запись WAL. Эффект не обнаружен — вероятно, WAL-буферы и так справляются при одном потоке.
### 4. VACUUM-ивенты — отдельная проблема
Генератор вставляет в очередь событие `VACUUM` каждую неделю модельного времени. Обработчик запускает `VACUUM ANALYZE` всей базы через `dblink_exec`. При 500k+ строках это занимает минуты. Решение: `DELETE FROM gen.events WHERE type = 'VACUUM'` перед `continue()` в инкрементальных скриптах.
## Итоговая конфигурация
```
BOOKINGS_JOBS=1 # синхронно, без dblink — ~3 мин на +1 день
BOOKINGS_INIT_DAYS=60 # seed-дамп покрывает ~34 дня бронирований + boarding_passes
```
Тюнинг PostgreSQL не требуется для текущих объёмов данных.
+29
View File
@@ -0,0 +1,29 @@
# Временное ТЗ по блоку bookings (для текущей разработки)
_Внутренний файл для наставника: поясняет, как устроен источник `bookings-db` и генерация данных. Студентам обычно не нужен._
- Контейнер `bookings-db` — отдельный сервис Postgres из `docker-compose.yml`, база по умолчанию `demo` (из upstream demodb), без переименований.
- Доступ снаружи не блокируем (порт `5434` по умолчанию), чтобы позже читать через PXF и подключаться из Greenplum.
- Инициализация: два способа:
- `make bookings-init` (рекомендуется): быстрое восстановление из seed-дампа (~18 сек).
- `make bookings-generate` (для разработчиков): полная генерация с нуля — клонирует demodb с закреплённым коммитом, накладывает патчи (`engine`: `jobs=1` синхронно + `busy()` игнорирует свой pid; `install.sql`: `DROP DATABASE IF EXISTS`, `connstr` без хардкода), ждёт `pg_isready`, ставит `gen.connstr` и GUC `bookings.start_date/init_days/jobs`, затем запускает `/bookings/generate_next_day.sql` через `psql -f`. Значения по умолчанию: стартовая дата 2017-01-01, `init_days=60`, `jobs=2`.
- Генерация следующего дня: `make bookings-generate-day` прогоняет тот же SQL (читает GUC, вызывает `generate/continue`, ждёт `busy()`, закрывает dblink). При `jobs=1` всё синхронно, без dblink.
- Исходники demodb: клонируем по требованию с фиксированным хешем, кладём в `bookings/demodb/` (в `.gitignore`), патчи лежат в `bookings/patches/` и применяются автоматически при `make bookings-generate`.
- Документация: в README описаны команды (`bookings-init`, `bookings-generate`, проверка данных, генерация дня), параметры `.env`; настройка PXF/ETL — следующий этап.
## Текущее состояние
- `make bookings-init` — быстрое восстановление из seed-дампа (~18 сек), рекомендуется для студентов.
- `make bookings-generate` — полная генерация с нуля: автоматически применяет патчи (`engine_jobs1_sync.patch`, `install_drop_if_exists.patch`), ждёт готовности Postgres через `pg_isready`, запускает `install.sql`, выставляет `gen.connstr`/GUC и вызывает `generate_next_day.sql` через `psql -f`.
- Дефолты: `BOOKINGS_START_DATE=2017-01-01`, `BOOKINGS_INIT_DAYS=60`, `BOOKINGS_JOBS=2`. При `jobs=1` генерация идёт синхронно без dblink, `busy()` не учитывает текущую сессию.
- `.env.example`/README обновлены под новые дефолты; каталог `bookings/demodb/` в `.gitignore`.
- Патчи лежат в `bookings/patches/` и накладываются при `bookings-clone-demodb`.
## Текущее состояние тестов/проблем
- Чистый прогон `make bookings-init` (восстановление из seed-дампа) проходит за ~18 секунд.
- Чистый прогон `make bookings-generate` (после `docker compose down -v` и удаления `bookings/demodb`) проходит за ~1,5 минуты: база ставится, `busy()` → `f`, `bookings.bookings` от `2017-01-01 00:00:18` до `2017-01-01 23:59:59`.
- Ранее зависание на `busy()` при `jobs=1` лечится патчем: `process_queue` теперь синхронный, а `busy()` игнорирует текущий backend.
- Данных пока только на 1 день по умолчанию, чтобы генерация не занимала много времени.
## Идеи/следующие шаги
- Если понадобится больше дней — увеличивать `BOOKINGS_INIT_DAYS`, но помнить, что генерация может идти долго; контролировать через `SELECT busy();`.
- Следующий этап — PXF/ETL в Greenplum; текущая задача — лишь подготовить источник bookings.
+201
View File
@@ -0,0 +1,201 @@
# PXF для bookings в учебном стенде (актуально)
Этот документ описывает **текущую реализацию** PXF в проекте: чтение данных из демо‑БД
`bookings` (Postgres, сервис `bookings-db`) в Greenplum через JDBC.
## 1. Что должно работать
- В Greenplum доступны внешние таблицы:
- `public.ext_bookings_bookings` (создаётся `make ddl-gp`);
- `stg.bookings_ext` (создаётся DAG `bookings_stg_ddl`).
- PXF должен быть готов **после каждого старта** контейнера `greenplum`.
## 2. Почему мы делаем свой образ Greenplum
Изначально PXF‑скрипты/конфиги монтировались в контейнер как bind‑mount `:ro`.
Базовый entrypoint образа Greenplum пытается делать `chown` файлов в
`/docker-entrypoint-initdb.d/`, из‑за чего контейнер иногда падал с ошибкой:
`chown: changing ownership ... Read-only file system`
Снять `:ro` тоже нежелательно — можно получить проблемы с правами на файлах хоста
(файл становится `root`, IDE перестаёт сохранять, появляются лишние изменения в git).
Решение для учебного стенда:
- собрать **свой образ** Greenplum (`Dockerfile.greenplum`);
- «вшить» в образ seed‑файлы и скрипты PXF;
- на каждом старте контейнера идемпотентно докладывать файлы в `PXF_BASE`,
который живёт на persistent volume.
## 3. Где что хранится
**Внутри образа (immutable):**
- seed для PXF: `/opt/pxf-seed/` (JDBC‑JAR и `servers/bookings-db/jdbc-site.xml`);
- скрипты:
- `/opt/pxf-scripts/ensure_pxf_bookings.sh` (подготовка `PXF_BASE`);
- `/start_greenplum_with_pxf.sh` (startup wrapper).
**На persistent volume (переживает рестарты):**
- `PXF_BASE` по умолчанию: `${GREENPLUM_DATA_DIRECTORY}/pxf` → в нашем compose это
`/data/pxf` на томе `greenplum_data`.
Важно: так как `PXF_BASE` лежит на томе, обновления seed‑файлов из нового образа
**не перезатирают** файлы в `PXF_BASE` автоматически (это сделано намеренно, чтобы
не ломать ручные правки студентов).
## 4. Что происходит при старте контейнера `greenplum`
1) Docker запускает контейнер с базовым entrypoint образа и командой
`/start_greenplum_with_pxf.sh` (она задана в `Dockerfile.greenplum` как `CMD`).
2) `/start_greenplum_with_pxf.sh` выполняет подготовку PXF:
- запускает ensure‑скрипт `/opt/pxf-scripts/ensure_pxf_bookings.sh`;
- параллельно пытается выполнить `CREATE EXTENSION IF NOT EXISTS pxf`
в базе `${GP_DB}` (по умолчанию `gp_dwh`), когда Greenplum начинает принимать
подключения.
3) Затем управление передаётся оригинальному старту Greenplum: `exec /start_gpdb.sh`.
4) Healthcheck сервиса `greenplum` ждёт и готовность Greenplum, и то, что PXF уже
запущен (`pxf cluster status`). Это нужно, чтобы Airflow не стартовал раньше PXF.
## 5. Управляющие переменные окружения
Все переменные можно задать в `.env` (см. `.env.example`):
- `PXF_SEED_OVERWRITE=1` — принудительно перезаписать seed‑файлы из образа в `PXF_BASE`
(обычно нужно после правок в каталоге `pxf/`).
- `PXF_SYNC_ON_START=1` — выполнять `pxf cluster sync` при старте контейнера
(делает старт чуть дольше, но гарантирует актуальные конфиги на хостах кластера).
## 6. Быстрая ручная проверка
1) Дождаться `healthy` у `greenplum`:
`docker compose ps`
2) Проверить статус PXF (PXF CLI запускается только под пользователем `gpadmin`):
`docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/pxf/bin/pxf cluster status'"`
3) После применения DDL (`make ddl-gp`) проверить чтение через PXF:
- `make gp-psql`
- `SELECT COUNT(*) FROM public.ext_bookings_bookings;`
## 7. Типовые ошибки
- `protocol "pxf" does not exist`
- причина: не создано расширение `pxf` в базе Greenplum;
- решение: перезапустить `greenplum` (скрипт сделает `CREATE EXTENSION IF NOT EXISTS pxf`)
или выполнить вручную `CREATE EXTENSION pxf;`.
- `Connection refused` к порту `5888`
- причина: PXF не поднялся/не успел подняться;
- решение: проверить `pxf cluster status`, посмотреть логи PXF в `/data/pxf/logs`,
перезапустить сервис `greenplum`.
- PXF «не подхватывает» изменения конфигов
- причина: файлы уже лежат в `PXF_BASE` на томе, а seed из образа по умолчанию не перетирает их;
- решение: `make build` + restart `greenplum` + (при необходимости) `PXF_SEED_OVERWRITE=1`.
## 8. Известная проблема: `protocol "pxf" does not exist` на «холодном старте» (исправлено)
Раньше (воспроизводилось в `./scripts/e2e_smoke.sh`) при первом `make ddl-gp` можно было получить:
`ERROR: protocol "pxf" does not exist`
### Почему так происходило
В базовом `/start_gpdb.sh` из образа Greenplum создание расширения `pxf` связано с проверкой
файла `${PXF_BASE}/conf/pxf-env.sh`:
- если `pxf-env.sh` **отсутствует**, скрипт выполняет `pxf cluster prepare/register` и затем
`CREATE EXTENSION IF NOT EXISTS pxf`;
- если `pxf-env.sh` **уже существует**, этот блок **пропускается**, и расширение может не появиться.
При этом наш ensure‑скрипт `pxf/init/10_pxf_bookings.sh` копировал `pxf-env.sh` в `${PXF_BASE}`
ещё до запуска Greenplum, из‑за чего базовый скрипт считал PXF “уже настроенным” и
пропускал создание расширения.
### Что изменили
- создание `extension pxf` вынесено в `start_greenplum_with_pxf.sh` и обёрнуто ретраями;
- `pxf-env.sh` по‑прежнему копируется в `PXF_BASE`, чтобы `/start_gpdb.sh` не пытался выполнять
`pxf cluster prepare` на непустом `PXF_BASE`;
- healthcheck `greenplum` ждёт не только PXF, но и наличие `extension pxf`.
- добавлен экспорт `PGPASSWORD` для `pxf cluster start`, чтобы `docker compose stop/start`
не ломал запуск из‑за `password authentication failed` для `gpadmin`.
### Если ошибка всё ещё возникает
1) Пересоберите образ и перезапустите контейнер `greenplum`:
`make build && make down && make up`
2) Проверьте наличие extension:
`docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -d gp_dwh -t -A -c \"SELECT extname FROM pg_extension WHERE extname = ''pxf'';\"'"`
## 9. Связанные файлы
- `Dockerfile.greenplum`
- `docker-compose.yml` (сервис `greenplum`: `build`, `hostname`, env, healthcheck)
- `pxf/init/10_pxf_bookings.sh` (ensure‑логика)
- `pxf/init/start_greenplum_with_pxf.sh` (старт контейнера)
- `docs/stack.md` (раздел «Greenplum + PXF: свой образ»)
## 10. Известная проблема: после `docker compose stop/start` Greenplum может упасть (auth для PXF)
### Симптом
После `docker compose stop`, затем `docker compose start` контейнер `greenplum` иногда уходит в `Exited (1)`.
В логах видно, что GPDB поднялся, но упал на старте PXF:
- `INFO - pxf cluster start`
- `ERROR: Could not connect to GPDB`
- `FATAL: password authentication failed for user "gpadmin"`
### Текущее понимание причины (почему это “иногда”)
1) При старте GPDB образ `woblerr/greenplum` генерирует/дописывает `pg_hba.conf` на persistent volume.
2) В `pg_hba.conf` присутствует trust‑правило для **конкретного IP** контейнера в docker‑сети
(пример из диагностики: `host all gpadmin 172.21.0.2/32 trust`).
3) После `docker compose stop/start` Docker может выдать контейнеру **другой IP** (например, `172.21.0.3`).
Тогда trust‑правило больше не подходит, и подключение начинает идти по `md5`.
4) `pxf cluster start` подключается к GPDB по TCP на `host=gpdbsne` (hostname контейнера),
то есть попадает именно в `pg_hba.conf` (а не в local‑auth).
5) В результате при “не совпавшем IP” получаем `md5` + пароль (возможно пустой/не тот) → падение на `28P01`.
Эта проблема выглядит флапающей, потому что IP после `stop/start` иногда совпадает с захардкоженным trust‑/32,
а иногда нет.
### Как подтвердить при следующем воспроизведении
1) Посмотреть логи `greenplum`:
`docker compose logs --tail=200 greenplum`
2) Найти реальный IP клиента в master‑логах GPDB (на томе):
`Password does not match ...` обычно содержит адрес вида `172.21.0.X`.
3) Сравнить его с trust‑строкой в `pg_hba.conf` на томе:
`/data/master/gpseg-1/pg_hba.conf`
Если IP в ошибке (например, `172.21.0.3`) **не** совпадает с trust‑/32 (например, `172.21.0.2/32`) —
это почти наверняка корень падения.
### Что с этим делать дальше (варианты решения, без реализации здесь)
Основная цель — убрать зависимость от “случайного IP после stop/start”:
- заставить `pxf cluster start` подключаться к GPDB через `127.0.0.1` (тогда работает существующий trust на localhost);
- или перестать добавлять в `pg_hba.conf` trust на конкретный `172.21.0.2/32` и заменить на более стабильное правило
(например, на подсеть docker‑сети или на `samehost`);
- или закрепить IP контейнера в compose (static IP), чтобы он не “плавал”;
- или отказаться от `stop/start` в пользу сценария, который не меняет сетевое окружение (но это хуже для UX студентов).
### Что реализовано
- В `pxf/init/start_greenplum_with_pxf.sh` добавлен шаг, который на каждом старте
обеспечивает в `pg_hba.conf` trust‑правило `host all gpadmin samehost trust`
(вставка перед `host all all 0.0.0.0/0 md5`), и делает `pg_ctl reload`, если GPDB уже запущен.
+595
View File
@@ -0,0 +1,595 @@
# План отладки эталонного пайплайна
> Цель: убедиться, что эталонный вертикальный срез (STG→ODS→DDS→DM) работает
> корректно при разных сценариях. Найти и исправить баги ДО того, как начнём
> готовить ветку main для студентов.
>
> Аудитория документа: AI-агент (Sonnet) или человек, выполняющий отладку.
>
> Зависимость: перед запуском этого плана нужно починить bookings-db
> (см. `docs/reference/bookings_db_issues.md`).
---
## Предусловия
```bash
make up # поднять стенд
make bookings-init # инициализировать bookings-db (демо-данные)
```
Все проверки выполняются через `make gp-psql` (psql к Greenplum) и Airflow REST API.
Для запуска DAG из CLI:
```bash
# Запуск DAG и получение run_id
curl -s -u admin:admin -X POST \
"http://localhost:8080/api/v1/dags/<DAG_ID>/dagRuns" \
-H "Content-Type: application/json" \
-d '{"conf":{}}' | jq .dag_run_id
# Проверка статуса
curl -s -u admin:admin \
"http://localhost:8080/api/v1/dags/<DAG_ID>/dagRuns?order_by=-start_date&limit=1" \
| jq '.dag_runs[0].state'
```
Перед началом тестов — убедиться, что `make test` проходит локально.
---
## Блок 1: Чистый прогон (Day 1)
**Цель:** убедиться, что пайплайн работает на свежих данных без ошибок.
### 1.1 Сброс и загрузка
```bash
make dwh-truncate # очистить все таблицы GP
```
Запустить DAG'и в порядке:
1. `bookings_stg_ddl` → дождаться success
2. `bookings_ods_ddl` → дождаться success
3. `bookings_dds_ddl` → дождаться success
4. `bookings_dm_ddl` → дождаться success
5. `bookings_to_gp_stage` → дождаться success
6. `bookings_to_gp_ods` → дождаться success
7. `bookings_to_gp_dds` → дождаться success
8. `bookings_to_gp_dm` → дождаться success
### 1.2 Проверки после Day 1
Все запросы выполнять в `make gp-psql`.
#### A. Непустота всех таблиц
```sql
-- STG: все 9 таблиц не пустые
SELECT 'stg.bookings' AS tbl, COUNT(*) FROM stg.bookings
UNION ALL SELECT 'stg.tickets', COUNT(*) FROM stg.tickets
UNION ALL SELECT 'stg.segments', COUNT(*) FROM stg.segments
UNION ALL SELECT 'stg.flights', COUNT(*) FROM stg.flights
UNION ALL SELECT 'stg.airports', COUNT(*) FROM stg.airports
UNION ALL SELECT 'stg.airplanes', COUNT(*) FROM stg.airplanes
UNION ALL SELECT 'stg.routes', COUNT(*) FROM stg.routes
UNION ALL SELECT 'stg.seats', COUNT(*) FROM stg.seats
UNION ALL SELECT 'stg.boarding_passes', COUNT(*) FROM stg.boarding_passes
ORDER BY 1;
-- ODS: все 9 таблиц не пустые
SELECT 'ods.bookings' AS tbl, COUNT(*) FROM ods.bookings
UNION ALL SELECT 'ods.tickets', COUNT(*) FROM ods.tickets
UNION ALL SELECT 'ods.segments', COUNT(*) FROM ods.segments
UNION ALL SELECT 'ods.flights', COUNT(*) FROM ods.flights
UNION ALL SELECT 'ods.airports', COUNT(*) FROM ods.airports
UNION ALL SELECT 'ods.airplanes', COUNT(*) FROM ods.airplanes
UNION ALL SELECT 'ods.routes', COUNT(*) FROM ods.routes
UNION ALL SELECT 'ods.seats', COUNT(*) FROM ods.seats
UNION ALL SELECT 'ods.boarding_passes', COUNT(*) FROM ods.boarding_passes
ORDER BY 1;
-- DDS: все 7 таблиц не пустые
SELECT 'dds.dim_calendar' AS tbl, COUNT(*) FROM dds.dim_calendar
UNION ALL SELECT 'dds.dim_airports', COUNT(*) FROM dds.dim_airports
UNION ALL SELECT 'dds.dim_airplanes', COUNT(*) FROM dds.dim_airplanes
UNION ALL SELECT 'dds.dim_tariffs', COUNT(*) FROM dds.dim_tariffs
UNION ALL SELECT 'dds.dim_passengers', COUNT(*) FROM dds.dim_passengers
UNION ALL SELECT 'dds.dim_routes', COUNT(*) FROM dds.dim_routes
UNION ALL SELECT 'dds.fact_flight_sales', COUNT(*) FROM dds.fact_flight_sales
ORDER BY 1;
-- DM: все 5 витрин не пустые
SELECT 'dm.sales_report' AS tbl, COUNT(*) FROM dm.sales_report
UNION ALL SELECT 'dm.route_performance', COUNT(*) FROM dm.route_performance
UNION ALL SELECT 'dm.passenger_loyalty', COUNT(*) FROM dm.passenger_loyalty
UNION ALL SELECT 'dm.airport_traffic', COUNT(*) FROM dm.airport_traffic
UNION ALL SELECT 'dm.monthly_overview', COUNT(*) FROM dm.monthly_overview
ORDER BY 1;
```
**Ожидание:** ВСЕ таблицы > 0 строк. Если какая-то пустая — это баг.
#### B. Сквозная сверка количеств (STG → ODS)
```sql
-- Инкрементальные таблицы: на Day 1 должно быть ODS = STG
SELECT
'bookings' AS entity,
(SELECT COUNT(*) FROM stg.bookings) AS stg_cnt,
(SELECT COUNT(*) FROM ods.bookings) AS ods_cnt
UNION ALL SELECT 'tickets',
(SELECT COUNT(*) FROM stg.tickets),
(SELECT COUNT(*) FROM ods.tickets)
UNION ALL SELECT 'segments',
(SELECT COUNT(*) FROM stg.segments),
(SELECT COUNT(*) FROM ods.segments)
UNION ALL SELECT 'flights',
(SELECT COUNT(*) FROM stg.flights),
(SELECT COUNT(*) FROM ods.flights)
UNION ALL SELECT 'boarding_passes',
(SELECT COUNT(*) FROM stg.boarding_passes),
(SELECT COUNT(*) FROM ods.boarding_passes);
```
**Ожидание Day 1:** `stg_cnt = ods_cnt` для инкрементальных таблиц.
```sql
-- Снапшот-таблицы: ODS = последний батч STG
SELECT
'airports' AS entity,
(SELECT COUNT(*) FROM stg.airports
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.airports)) AS stg_cnt,
(SELECT COUNT(*) FROM ods.airports) AS ods_cnt
UNION ALL SELECT 'airplanes',
(SELECT COUNT(*) FROM stg.airplanes
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.airplanes)),
(SELECT COUNT(*) FROM ods.airplanes)
UNION ALL SELECT 'routes',
(SELECT COUNT(*) FROM stg.routes
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.routes)),
(SELECT COUNT(*) FROM ods.routes)
UNION ALL SELECT 'seats',
(SELECT COUNT(*) FROM stg.seats
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.seats)),
(SELECT COUNT(*) FROM ods.seats);
```
**Ожидание:** `stg_cnt = ods_cnt` для снапшот-таблиц.
#### C. Сквозная сверка количеств (ODS → DDS)
```sql
-- Измерения SCD1: число уникальных бизнес-ключей
SELECT
'airports' AS entity,
(SELECT COUNT(DISTINCT airport_code) FROM ods.airports) AS ods_bk,
(SELECT COUNT(*) FROM dds.dim_airports) AS dds_cnt
UNION ALL SELECT 'airplanes',
(SELECT COUNT(DISTINCT airplane_code) FROM ods.airplanes),
(SELECT COUNT(*) FROM dds.dim_airplanes)
UNION ALL SELECT 'tariffs',
(SELECT COUNT(DISTINCT fare_conditions) FROM ods.segments),
(SELECT COUNT(*) FROM dds.dim_tariffs)
UNION ALL SELECT 'passengers',
(SELECT COUNT(DISTINCT passenger_id) FROM ods.tickets),
(SELECT COUNT(*) FROM dds.dim_passengers);
```
**Ожидание:** `ods_bk = dds_cnt` (на первый прогон, без SCD-истории).
```sql
-- Маршруты SCD2: текущих записей = уникальных бизнес-ключей в ODS
-- Примечание: бизнес-ключ = route_no (не route_no || '-' || validity).
-- Один route_no может иметь несколько периодов validity в ods.routes,
-- но DDS берёт только самый свежий (rn=1 по validity DESC).
SELECT
(SELECT COUNT(DISTINCT route_no) FROM ods.routes) AS ods_routes,
(SELECT COUNT(*) FROM dds.dim_routes WHERE valid_to IS NULL) AS dds_current,
(SELECT COUNT(*) FROM dds.dim_routes) AS dds_total;
```
**Ожидание Day 1:** `ods_routes = dds_current = dds_total` (без истории).
```sql
-- Факт: grain = segments
SELECT
(SELECT COUNT(*) FROM ods.segments) AS ods_segments,
(SELECT COUNT(*) FROM dds.fact_flight_sales) AS dds_fact;
```
**Ожидание:** `ods_segments = dds_fact`.
#### D. Сквозная сверка: DDS → DM (бизнес-метрики)
```sql
-- Общая выручка: fact vs sales_report
-- Примечание: колонка называется price (не ticket_price)
SELECT
(SELECT SUM(price) FROM dds.fact_flight_sales) AS fact_revenue,
(SELECT SUM(total_revenue) FROM dm.sales_report) AS dm_revenue;
```
**Ожидание:** `fact_revenue = dm_revenue` (или объяснимая разница из-за логики витрины).
```sql
-- Количество уникальных пассажиров: fact vs passenger_loyalty
SELECT
(SELECT COUNT(DISTINCT passenger_sk) FROM dds.fact_flight_sales
WHERE passenger_sk IS NOT NULL) AS fact_passengers,
(SELECT COUNT(*) FROM dm.passenger_loyalty) AS dm_passengers;
```
**Ожидание:** совпадение (или объяснимая разница).
```sql
-- Общее число посадок: fact vs sales_report
-- Примечание: колонка называется is_boarded (не boarding_seq)
SELECT
(SELECT COUNT(*) FROM dds.fact_flight_sales
WHERE is_boarded = TRUE) AS fact_boarded,
(SELECT SUM(passengers_boarded) FROM dm.sales_report) AS dm_boarded;
```
#### E. Целостность суррогатных ключей (FK в DDS и DM)
```sql
SELECT 'orphan_route_sk' AS check_name, COUNT(*) AS orphans
FROM dds.fact_flight_sales f
LEFT JOIN dds.dim_routes r ON f.route_sk = r.route_sk
WHERE f.route_sk IS NOT NULL AND r.route_sk IS NULL
UNION ALL
SELECT 'orphan_airport_sk', COUNT(*)
FROM dm.sales_report sr
LEFT JOIN dds.dim_airports a ON sr.airport_sk = a.airport_sk
WHERE sr.airport_sk IS NOT NULL AND a.airport_sk IS NULL
UNION ALL
SELECT 'orphan_tariff_sk', COUNT(*)
FROM dds.fact_flight_sales f
LEFT JOIN dds.dim_tariffs t ON f.tariff_sk = t.tariff_sk
WHERE f.tariff_sk IS NOT NULL AND t.tariff_sk IS NULL
UNION ALL
SELECT 'orphan_passenger_sk', COUNT(*)
FROM dds.fact_flight_sales f
LEFT JOIN dds.dim_passengers p ON f.passenger_sk = p.passenger_sk
WHERE f.passenger_sk IS NOT NULL AND p.passenger_sk IS NULL
UNION ALL
SELECT 'orphan_calendar_sk', COUNT(*)
FROM dds.fact_flight_sales f
LEFT JOIN dds.dim_calendar c ON f.calendar_sk = c.calendar_sk
WHERE f.calendar_sk IS NOT NULL AND c.calendar_sk IS NULL;
```
**Ожидание:** ВСЕ orphans = 0.
---
## Блок 2: Идемпотентность (повторный Day 1)
**Цель:** повторный прогон ODS/DDS/DM НЕ дублирует данные при неизменённом STG.
**Важно:** `bookings_to_gp_stage` ВСЕГДА генерирует следующий день при запуске
(через `generate_bookings_day`). Тест идемпотентности нужно проводить только для
`bookings_to_gp_ods`, `bookings_to_gp_dds`, `bookings_to_gp_dm` — без повторного
запуска STG. Убедитесь, что все STG-батчи уже обработаны ODS перед тестом:
```sql
SELECT COUNT(*) AS unprocessed
FROM stg.bookings
WHERE _load_ts > (SELECT COALESCE(MAX(_load_ts), '1900-01-01') FROM ods.bookings);
-- Ожидание: 0
```
### 2.1 Зафиксировать counts после Day 1
```sql
SELECT 'ods.bookings' AS tbl, COUNT(*) AS cnt FROM ods.bookings
UNION ALL SELECT 'ods.tickets', COUNT(*) FROM ods.tickets
UNION ALL SELECT 'ods.segments', COUNT(*) FROM ods.segments
UNION ALL SELECT 'ods.flights', COUNT(*) FROM ods.flights
UNION ALL SELECT 'dds.fact_flight_sales', COUNT(*) FROM dds.fact_flight_sales
UNION ALL SELECT 'dds.dim_routes', COUNT(*) FROM dds.dim_routes
UNION ALL SELECT 'dm.sales_report', COUNT(*) FROM dm.sales_report
UNION ALL SELECT 'dm.passenger_loyalty', COUNT(*) FROM dm.passenger_loyalty
ORDER BY 1;
```
Записать результаты.
### 2.2 Повторный прогон (без генерации нового дня!)
Запустить снова: STG → ODS → DDS → DM (4 load-DAG'а).
### 2.3 Проверить, что counts не изменились
Повторить запрос из 2.1 и сравнить.
**Ожидание:**
- Инкрементальные (bookings, tickets, segments, flights, boarding_passes,
fact_flight_sales) — count НЕ увеличился
- Снапшоты (airports, airplanes, routes, seats) — count тот же
- DM (full rebuild) — count тот же
**Если count вырос — баг идемпотентности.** Записать, в какой таблице и на сколько.
---
## Блок 3: Инкремент (Day 2)
**Цель:** после генерации нового дня инкрементальные таблицы растут,
снапшоты обновляются, витрины обогащаются.
### 3.1 Генерация нового дня
```bash
make bookings-generate-day
```
### 3.2 Запуск пайплайна
Запустить STG → ODS → DDS → DM (4 load-DAG'а).
### 3.3 Проверки после Day 2
#### A. Инкрементальные таблицы выросли
```sql
SELECT 'stg.bookings' AS tbl, COUNT(*) FROM stg.bookings
UNION ALL SELECT 'ods.bookings', COUNT(*) FROM ods.bookings
UNION ALL SELECT 'ods.tickets', COUNT(*) FROM ods.tickets
UNION ALL SELECT 'ods.segments', COUNT(*) FROM ods.segments
UNION ALL SELECT 'dds.fact_flight_sales', COUNT(*) FROM dds.fact_flight_sales
ORDER BY 1;
```
**Ожидание:** все count'ы > Day 1.
#### B. STG хранит оба батча
```sql
SELECT _load_id, COUNT(*) FROM stg.bookings GROUP BY 1 ORDER BY 1;
```
**Ожидание:** 2 разных `_load_id`, оба с данными.
#### C. Снапшоты не дублировались
```sql
SELECT
'airports' AS entity,
(SELECT COUNT(*) FROM stg.airports
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.airports)) AS stg_last_batch,
(SELECT COUNT(*) FROM ods.airports) AS ods_cnt
UNION ALL SELECT 'airplanes',
(SELECT COUNT(*) FROM stg.airplanes
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.airplanes)),
(SELECT COUNT(*) FROM ods.airplanes)
UNION ALL SELECT 'routes',
(SELECT COUNT(*) FROM stg.routes
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.routes)),
(SELECT COUNT(*) FROM ods.routes)
UNION ALL SELECT 'seats',
(SELECT COUNT(*) FROM stg.seats
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.seats)),
(SELECT COUNT(*) FROM ods.seats);
```
**Ожидание:** `stg_last_batch = ods_cnt`.
#### D. SCD2 dim_routes — версионность
```sql
SELECT
COUNT(*) AS total_rows,
COUNT(*) FILTER (WHERE valid_to IS NULL) AS current_rows,
COUNT(*) FILTER (WHERE valid_to IS NOT NULL) AS closed_rows
FROM dds.dim_routes;
```
**Ожидание:** `closed_rows = 0` если справочник маршрутов не менялся.
Если `closed_rows > 0` — проверить, действительно ли атрибуты изменились:
```sql
SELECT route_bk, valid_from, valid_to, hashdiff
FROM dds.dim_routes
WHERE route_bk IN (
SELECT route_bk FROM dds.dim_routes GROUP BY route_bk HAVING COUNT(*) > 1
)
ORDER BY route_bk, valid_from;
```
#### E. DM после инкремента
```sql
SELECT MIN(flight_date), MAX(flight_date), COUNT(DISTINCT flight_date)
FROM dm.sales_report;
```
**Ожидание:** диапазон дат шире, чем после Day 1.
---
## Блок 4: Многодневный прогон (Days 3-5)
**Цель:** поймать баги, которые проявляются только при накоплении данных.
### 4.1 Цикл
Повторить 3 раза:
```bash
make bookings-generate-day
# Запустить STG → ODS → DDS → DM
```
### 4.2 Проверки после 5 дней
#### A. Монотонный рост инкрементальных таблиц
```sql
SELECT _load_id, COUNT(*) FROM stg.bookings GROUP BY 1 ORDER BY 1;
```
**Ожидание:** 5 строк, все с данными.
#### B. Нет дупликатов в ODS (критически важно!)
```sql
SELECT 'ods.bookings' AS tbl,
COUNT(*) - COUNT(DISTINCT book_ref) AS dups FROM ods.bookings
UNION ALL SELECT 'ods.tickets',
COUNT(*) - COUNT(DISTINCT ticket_no) FROM ods.tickets
UNION ALL SELECT 'ods.flights',
COUNT(*) - COUNT(DISTINCT flight_id) FROM ods.flights
UNION ALL SELECT 'ods.segments',
COUNT(*) - COUNT(DISTINCT ticket_no || '-' || flight_id::text) FROM ods.segments
UNION ALL SELECT 'ods.boarding_passes',
COUNT(*) - COUNT(DISTINCT ticket_no || '-' || flight_id::text) FROM ods.boarding_passes;
```
**Ожидание:** ВСЕ dups = 0. Если > 0 — **критический баг**.
#### C. Нет дупов в DDS fact
```sql
SELECT COUNT(*) - COUNT(DISTINCT ticket_no || '-' || flight_id::text) AS dups
FROM dds.fact_flight_sales;
```
**Ожидание:** 0.
#### D. Рост витрин осмысленный
```sql
SELECT
(SELECT COUNT(*) FROM dm.sales_report) AS sales_rows,
(SELECT COUNT(*) FROM dm.route_performance) AS route_rows,
(SELECT COUNT(*) FROM dm.passenger_loyalty) AS passenger_rows,
(SELECT COUNT(*) FROM dm.airport_traffic) AS traffic_rows,
(SELECT COUNT(*) FROM dm.monthly_overview) AS monthly_rows;
```
Сравнить с Day 1. Ожидание: `sales_rows` и `traffic_rows` растут (по дням),
`route_rows` стабильны (по маршрутам), `passenger_rows` растут или стабильны.
---
## Блок 5: Валидация SQL-скриптов (статический анализ)
**Цель:** проверить качество кода без запуска стенда. Можно делать параллельно
с блоками 1-4.
### 5.1 Консистентность _load_id во всех слоях
```bash
# В STG/ODS/DDS/DM должен быть _load_id
grep -r '_load_id' sql/stg/*_load.sql sql/ods/*_load.sql sql/dds/*_load.sql sql/dm/*_load.sql | head -20
# Не должно быть старых имён batch_id, load_dttm, src_created_at_ts
grep -r '\bbatch_id\b' sql/stg/ sql/ods/ sql/dds/ sql/dm/ # ожидание: только допустимые переменные PL (v_batch_id)
grep -r 'load_dttm\|src_created_at_ts' sql/ # ожидание: пусто
```
### 5.2 Все load.sql используют шаблон {{ run_id }}
Примечание: ODS намеренно не использует `{{ run_id }}` — вместо этого в `_load_id`
сохраняется `_load_id` из STG для сквозного lineage (traceable to source batch).
Это правильный паттерн, а не баг. Проверять нужно только STG/DDS/DM.
```bash
for f in sql/stg/*_load.sql sql/dds/*_load.sql sql/dm/*_load.sql; do
if ! grep -q '{{ run_id }}\|{{ ti.xcom_pull' "$f"; then
echo "WARN: $f не содержит {{ run_id }}"
fi
done
```
### 5.3 DDL и load совпадают по набору колонок
Для каждой пары `_ddl.sql` / `_load.sql`:
- Извлечь список колонок из DDL (CREATE TABLE)
- Извлечь список колонок из INSERT в load.sql
- Сравнить
**Ожидание:** списки совпадают (за исключением SERIAL/GENERATED колонок).
Приоритетные пары для ручной проверки (сложные):
- `dds/fact_flight_sales` (много FK)
- `dds/dim_routes` (SCD2-поля)
- `dm/sales_report` (агрегаты)
### 5.4 DQ-скрипты согласованы с DDL
Для каждого `_dq.sql` проверить:
- Все NOT NULL колонки из DDL проверяются в DQ?
- Все бизнес-ключи из DDL проверяются на дупликаты?
- FK-проверки ссылаются на правильные таблицы?
---
## Блок 6: Граничные случаи
### 6.1 Пустой инкремент
Запустить STG DAG БЕЗ предварительной генерации нового дня.
**Ожидание:** DAG завершается success (не failure). Таблицы не меняются.
DQ-скрипты не падают на пустом батче.
### 6.2 DDL DAG на уже существующих таблицах
Запустить `bookings_stg_ddl` повторно (таблицы уже есть).
**Ожидание:** success. DDL использует `IF NOT EXISTS`. Данные не потеряны.
### 6.3 Служебные поля заполнены
```sql
SELECT 'ods.bookings' AS tbl,
COUNT(*) FILTER (WHERE _load_id IS NULL) AS null_load_id,
COUNT(*) FILTER (WHERE _load_ts IS NULL) AS null_load_ts
FROM ods.bookings
UNION ALL SELECT 'dds.fact_flight_sales',
COUNT(*) FILTER (WHERE _load_id IS NULL),
COUNT(*) FILTER (WHERE _load_ts IS NULL)
FROM dds.fact_flight_sales
UNION ALL SELECT 'dds.dim_routes',
COUNT(*) FILTER (WHERE _load_id IS NULL),
COUNT(*) FILTER (WHERE _load_ts IS NULL)
FROM dds.dim_routes;
```
**Ожидание:** все null_* = 0.
---
## Формат отчёта
По каждому блоку фиксировать:
| Блок | Проверка | Статус | Детали |
|------|----------|--------|--------|
| 1.2A | Непустота таблиц | OK / FAIL | какая таблица пуста |
| 1.2B | STG→ODS counts | OK / FAIL | расхождение: X vs Y |
| ... | ... | ... | ... |
Если найден баг:
1. Описать симптом (какой запрос, какой результат)
2. Локализовать (в каком SQL-файле проблема)
3. Предложить fix
4. После fix — повторить проверку
---
## Приоритеты (если время ограничено)
1. **Блок 1** (чистый прогон) — обязательно, базовый smoke
2. **Блок 2** (идемпотентность) — обязательно, частый источник багов
3. **Блок 3** (инкремент Day 2) — обязательно, проверяет главную фичу
4. **Блок 5.3** (DDL ↔ load) — высокий приоритет, ловит рассинхрон колонок
5. **Блок 4** (5 дней) — средний приоритет, ловит накопительные баги
6. **Блок 6** (граничные) — средний приоритет
7. **Блок 5.1-5.4** (статика) — можно делать параллельно без стенда
+135
View File
@@ -0,0 +1,135 @@
# Технические детали стенда (Docker / Airflow / Greenplum)
Этот документ не обязателен для прохождения лабораторных, но полезен, если вы:
- хотите понять, как устроен стенд “под капотом”;
- меняете конфиги/зависимости и пересобираете образы;
- отлаживаете проблемы со стартом Greenplum/PXF или Connections в Airflow.
## Make (опционально, но удобно)
Если `make` не установлен, можно пользоваться `docker compose ...`.
Но с `make` команды короче и проще.
Установка `make` (если нужно):
- Linux (Debian/Ubuntu): `sudo apt install -y make`
- macOS: `brew install make`
- Windows:
- WSL: `sudo apt install -y make`
- Chocolatey: `choco install make`
- Scoop: `scoop install make`
Примеры:
```bash
make up
make logs
make gp-psql
```
## Airflow Connections
Готовые DAG по умолчанию подключаются к БД через Airflow Connections:
- `greenplum_conn` — Greenplum;
- `bookings_db` — демо‑БД bookings (`bookings-db`).
В `docker-compose.yml` Connections задаются через переменные окружения `AIRFLOW_CONN_...`,
поэтому они могут не отображаться в UI — для DAG это нормально.
Если нужно завести вручную:
1) Airflow UI → Admin → Connections → Add a new record
2) Пример для Greenplum:
- Conn Id: `greenplum_conn`
- Conn Type: `Postgres`
- Host: `greenplum`
- Schema: `gp_dwh`
- Login/Password: `gpadmin` / `gpadmin`
- Port: `5432`
## PXF (в 5 строк)
PXF (Platform Extension Framework) — компонент Greenplum для работы с внешними источниками.
В этом стенде PXF используется для чтения таблицы `bookings.bookings` из Postgres прямо из Greenplum
через внешнюю таблицу `stg.bookings_ext`. Поэтому загрузка в `stg.bookings` выглядит как обычный
`INSERT ... SELECT` без промежуточных файлов.
## Greenplum + PXF: свой образ
Greenplum собирается из собственного `Dockerfile.greenplum`, чтобы:
- не ловить проблемы с правами/`chown` при bind‑mount конфигов;
- держать PXF‑конфиги и JDBC‑драйвер внутри образа;
- делать старт контейнера идемпотентным.
При старте контейнера:
- seed‑конфиги PXF докладываются в `PXF_BASE` на persistent volume;
- создаются каталоги `PXF_BASE/run` и `PXF_BASE/logs`;
- расширение `pxf` создаётся автоматически, когда Greenplum становится доступен (с ретраями).
Полезные команды:
```bash
make build # пересобрать образы
docker compose ps # проверить health
```
Перезапись seed‑файлов в `PXF_BASE`:
- `PXF_SEED_OVERWRITE=1` — перезаписать конфиги при старте;
- `PXF_SYNC_ON_START=1` — выполнить `pxf cluster sync` при старте.
Подробнее: `docs/reference/pxf_bookings.md`.
## Airflow: свой образ
Airflow тоже собирается из собственного `Dockerfile.airflow`, чтобы зависимости ставились при сборке,
а не во время старта контейнеров.
Если меняли `airflow/requirements.txt` или `Dockerfile.airflow`, пересоберите образы:
```bash
make build
make up
```
## Локальное окружение разработчика (uv)
Локальным окружением управляет `uv` — он создаёт `.venv` из `pyproject.toml` / `uv.lock`.
```bash
uv sync
make test
make lint
make fmt
```
Не устанавливайте зависимости через `pip install --user ...` — это часто ломает окружение и IDE.
## Переменные окружения (`.env`)
Все настройки лежат в `.env` (шаблон: `.env.example`).
### Порты на хосте
- `AIRFLOW_WEB_PORT` — Airflow UI (по умолчанию `8080`)
- `PGMETA_PORT` — мета‑БД Airflow (по умолчанию `5433`, обычно нужно только для диагностики)
- `BOOKINGS_DB_PORT` — bookings-db (по умолчанию `5434`)
- `GP_HOST_PORT` — Greenplum (по умолчанию `5435`)
### Greenplum
- `GP_USER`, `GP_PASSWORD`, `GP_DB`
- `GP_PORT` — внутренний порт в Docker-сети (обычно `5432`)
- `GP_HOST_PORT` — внешний порт на хосте (по умолчанию `5435`)
### bookings-db (Postgres)
- `BOOKINGS_DB_USER`, `BOOKINGS_DB_PASSWORD`
- `BOOKINGS_DB_PORT` — внешний порт (по умолчанию `5434`)
- `BOOKINGS_START_DATE` — стартовая дата модельного времени
- `BOOKINGS_INIT_DAYS` — сколько дней генерировать при `make bookings-generate` (генерация с нуля)
- `BOOKINGS_JOBS` — число джобов генератора (по умолчанию `1`)
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Идемпотентная подготовка PXF на каждом запуске контейнера:
# - копирует JDBC-драйвер и конфиг сервера из образа в PXF_BASE;
# - не перезаписывает файлы, если не задан PXF_SEED_OVERWRITE=1;
# - синхронизацию pxf выполняет только при PXF_SYNC_ON_START=1.
set -euo pipefail
PXF_SEED_DIR="${PXF_SEED_DIR:-/opt/pxf-seed}"
PXF_CONF_SEED_DIR="${PXF_CONF_SEED_DIR:-/usr/local/pxf/conf}"
PXF_BASE_DEFAULT="${GREENPLUM_DATA_DIRECTORY:-/data}/pxf"
PXF_BASE="${PXF_BASE:-$PXF_BASE_DEFAULT}"
PXF_SEED_OVERWRITE="${PXF_SEED_OVERWRITE:-0}"
PXF_SYNC_ON_START="${PXF_SYNC_ON_START:-0}"
PXF_CLI="${PXF_CLI:-/usr/local/pxf/bin/pxf}"
GP_USER="${GREENPLUM_USER:-gpadmin}"
log_info() {
echo "INFO - $*"
}
log_warn() {
echo "WARN - $*"
}
copy_seed_file() {
local src="$1"
local dst="$2"
local label="$3"
if [ ! -f "${src}" ]; then
log_warn "seed-файл не найден: ${src}"
return 0
fi
if [ "${PXF_SEED_OVERWRITE}" = "1" ] || [ ! -f "${dst}" ]; then
cp -f "${src}" "${dst}"
log_info "${label}: установлено в ${dst}"
return 0
fi
log_info "${label}: уже существует, пропускаем"
}
mkdir -p "${PXF_BASE}/lib" "${PXF_BASE}/servers/bookings-db" "${PXF_BASE}/conf"
mkdir -p "${PXF_BASE}/run" "${PXF_BASE}/logs"
copy_seed_file \
"${PXF_SEED_DIR}/postgresql-42.7.3.jar" \
"${PXF_BASE}/lib/postgresql-jdbc.jar" \
"JDBC драйвер PostgreSQL"
copy_seed_file \
"${PXF_SEED_DIR}/servers/bookings-db/jdbc-site.xml" \
"${PXF_BASE}/servers/bookings-db/jdbc-site.xml" \
"jdbc-site.xml для bookings-db"
copy_seed_file \
"${PXF_CONF_SEED_DIR}/pxf-application.properties" \
"${PXF_BASE}/conf/pxf-application.properties" \
"pxf-application.properties"
copy_seed_file \
"${PXF_CONF_SEED_DIR}/pxf-env.sh" \
"${PXF_BASE}/conf/pxf-env.sh" \
"pxf-env.sh"
# Устанавливаем уменьшенные JVM-опции, если они ещё не заданы явно
if [ -f "${PXF_BASE}/conf/pxf-env.sh" ]; then
if ! grep -Eq '^[[:space:]]*export[[:space:]]+PXF_JVM_OPTS=' "${PXF_BASE}/conf/pxf-env.sh"; then
echo 'export PXF_JVM_OPTS="-Xmx512m -Xms256m"' >> "${PXF_BASE}/conf/pxf-env.sh"
log_info "PXF_JVM_OPTS: установлен уменьшенный профиль памяти"
fi
fi
copy_seed_file \
"${PXF_CONF_SEED_DIR}/pxf-log4j2.xml" \
"${PXF_BASE}/conf/pxf-log4j2.xml" \
"pxf-log4j2.xml"
copy_seed_file \
"${PXF_CONF_SEED_DIR}/pxf-profiles.xml" \
"${PXF_BASE}/conf/pxf-profiles.xml" \
"pxf-profiles.xml"
if [ "${PXF_SYNC_ON_START}" = "1" ]; then
if [ ! -x "${PXF_CLI}" ]; then
log_warn "pxf cli не найден: ${PXF_CLI}, пропускаем pxf cluster sync"
exit 0
fi
# PXF CLI не запускается под root, поэтому выполняем синхронизацию под gpadmin.
# PXF_BASE передаём явно, т.к. `su -` сбрасывает окружение.
if ! su - "${GP_USER}" -c "PXF_BASE='${PXF_BASE}' '${PXF_CLI}' cluster sync"; then
log_warn "pxf cluster sync завершился с ошибкой"
else
log_info "pxf cluster sync выполнен"
fi
fi
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
set -euo pipefail
# Этот скрипт — "обёртка" над стандартным стартом Greenplum (exec /start_gpdb.sh),
# которая добавляет устойчивый старт PXF для учебного стенда.
#
# Задачи скрипта (почему он нужен):
# 1) Подготовить PXF_BASE на persistent volume (через ensure-скрипт).
# 2) Обеспечить стабильную аутентификацию PXF → GPDB после `docker compose stop/start`
# (не зависеть от «плавающего» IP контейнера в docker-сети).
# 3) Гарантировать наличие расширения `pxf` в БД GPDB (иначе внешние таблицы падают с
# `ERROR: protocol "pxf" does not exist`).
#
# Важно: это учебный стенд, поэтому мы сознательно выбираем простые и надёжные решения
# (например, trust для samehost), а не «боевой» hardened security.
ensure_script="/opt/pxf-scripts/ensure_pxf_bookings.sh"
# PXF CLI использует libpq и при `pxf cluster start` подключается к GPDB как к обычному Postgres.
# После `docker compose stop/start` может внезапно потребоваться пароль (см. ensure_pg_hba_trust ниже),
# поэтому экспортируем PGPASSWORD заранее: это уменьшает "флап" и делает поведение воспроизводимым.
if [ -z "${PGPASSWORD:-}" ]; then
export PGPASSWORD="${GREENPLUM_PASSWORD:-gpadmin}"
fi
ensure_pg_hba_trust() {
# После `docker compose stop/start` Docker может выдать контейнеру другой IP.
# У базового образа Greenplum встречается trust-правило на конкретный /32 (старый IP),
# и тогда аутентификация по TCP начинает идти через md5 → PXF падает на `28P01`.
#
# Решение: добавить правило `host all gpadmin samehost trust`, которое срабатывает для
# подключений "с этого же контейнера" (PXF запускается рядом с master).
# Это максимально простое и стабильное правило для учебного стенда.
local gp_user="${GREENPLUM_USER:-gpadmin}"
local data_dir="${GREENPLUM_DATA_DIRECTORY:-/data}"
local attempts=60
local pg_hba=""
local trust_line="host all ${gp_user} samehost trust"
find_pg_hba() {
local candidate
for candidate in "${data_dir}/master"/*/pg_hba.conf; do
if [ -f "${candidate}" ]; then
echo "${candidate}"
return 0
fi
done
return 1
}
for _ in $(seq 1 "${attempts}"); do
pg_hba="$(find_pg_hba || true)"
if [ -n "${pg_hba}" ]; then
break
fi
sleep 2
done
if [ -z "${pg_hba}" ]; then
echo "WARN - pg_hba.conf не найден, пропускаем trust для ${gp_user}"
return 0
fi
if grep -Eq "^[[:space:]]*host[[:space:]]+all[[:space:]]+${gp_user}[[:space:]]+samehost[[:space:]]+trust" "${pg_hba}"; then
echo "INFO - pg_hba.conf уже содержит trust для ${gp_user} samehost"
return 0
fi
# Вставляем trust-правило перед самым "общим" md5-правилом (0.0.0.0/0),
# чтобы samehost гарантированно матчился раньше.
awk -v trust_line="${trust_line}" '
BEGIN { added = 0 }
$0 ~ /^[[:space:]]*host[[:space:]]+all[[:space:]]+all[[:space:]]+0\.0\.0\.0\/0[[:space:]]+md5/ && added == 0 {
print trust_line
added = 1
}
{ print }
END {
if (added == 0) {
print trust_line
}
}
' "${pg_hba}" > "${pg_hba}.tmp" && mv "${pg_hba}.tmp" "${pg_hba}"
echo "INFO - pg_hba.conf: добавлен trust для ${gp_user} samehost"
local master_dir="${pg_hba%/pg_hba.conf}"
# Если GPDB уже поднялся, достаточно reload, чтобы новое правило применилось без рестарта.
if /usr/local/greenplum-db/bin/pg_ctl -D "${master_dir}" status >/dev/null 2>&1; then
if ! /usr/local/greenplum-db/bin/pg_ctl -D "${master_dir}" reload >/dev/null 2>&1; then
echo "WARN - не удалось перезагрузить pg_hba.conf (pg_ctl reload)"
fi
fi
}
ensure_pxf_extension() {
# В базовом /start_gpdb.sh создание `CREATE EXTENSION pxf` зависит от наличия
# `${PXF_BASE}/conf/pxf-env.sh`. В нашем стенде pxf-env.sh может быть уже создан
# ensure-скриптом (PXF_BASE на томе), и тогда /start_gpdb.sh пропускает extension.
#
# Чтобы внешние таблицы через PXF работали после любого рестарта, создаём extension сами,
# но только когда GPDB начнёт принимать подключения (с ретраями).
local gp_user="${GREENPLUM_USER:-gpadmin}"
local gp_db="${GREENPLUM_DATABASE_NAME:-gp_dwh}"
local gp_password="${GREENPLUM_PASSWORD:-gpadmin}"
local attempts=60
if [ "${GREENPLUM_PXF_ENABLE:-false}" != "true" ]; then
return 0
fi
extension_exists() {
local result
# Подключаемся к localhost: это "локальный" путь и на нём обычно уже есть trust.
result=$(PGPASSWORD="${gp_password}" /usr/local/greenplum-db/bin/psql \
-h 127.0.0.1 -p 5432 -U "${gp_user}" -d "${gp_db}" \
-t -A -c "SELECT 1 FROM pg_extension WHERE extname='pxf';" 2>/dev/null || true)
[ "${result}" = "1" ]
}
for attempt in $(seq 1 "${attempts}"); do
# Ждём, пока master начнёт принимать подключения.
if /usr/local/greenplum-db/bin/pg_isready \
-h 127.0.0.1 -p 5432 -U "${gp_user}" -d "${gp_db}" >/dev/null 2>&1; then
if extension_exists; then
echo "INFO - extension pxf уже создано"
return 0
fi
if PGPASSWORD="${gp_password}" /usr/local/greenplum-db/bin/psql \
-h 127.0.0.1 -p 5432 -U "${gp_user}" -d "${gp_db}" \
-v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS pxf;" >/dev/null 2>&1; then
if extension_exists; then
echo "INFO - extension pxf готово"
return 0
fi
fi
echo "WARN - попытка ${attempt}/${attempts}: не удалось создать extension pxf"
fi
sleep 2
done
echo "WARN - Greenplum не готов или extension pxf не создано"
}
# 1) Подготовка PXF_BASE (копирование seed, конфигов и т.д.).
if [ -f "${ensure_script}" ]; then
"${ensure_script}"
else
echo "WARN - не найден ensure-скрипт PXF: ${ensure_script}"
fi
# 2) Дальше запускаем две "подстраховки" параллельно, чтобы не замедлять старт контейнера:
# - правка pg_hba.conf (как только он появится на томе);
# - создание extension pxf (как только GPDB начнёт отвечать).
ensure_pg_hba_trust &
ensure_pxf_extension &
# 3) Стартуем GPDB "как обычно". Важно использовать exec, чтобы сигналы Docker
# (stop/restart) корректно приходили в основной процесс entrypoint.
exec /start_gpdb.sh
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
JDBC-конфиг для PXF-сервера bookings-db.
Для учебного стенда используем те же дефолтные креды, что и в .env.example:
BOOKINGS_DB_USER=bookings, BOOKINGS_DB_PASSWORD=bookings.
При изменении этих параметров в .env не забывайте обновить их здесь.
-->
<configuration>
<property>
<name>jdbc.driver</name>
<value>org.postgresql.Driver</value>
</property>
<property>
<name>jdbc.url</name>
<value>jdbc:postgresql://bookings-db:5432/demo</value>
</property>
<property>
<name>jdbc.user</name>
<value>bookings</value>
</property>
<property>
<name>jdbc.password</name>
<value>bookings</value>
</property>
<property>
<name>jdbc.column.types</name>
<value>jsonb=TEXT,tstzrange=TEXT,_int4=TEXT</value>
</property>
</configuration>
+4
View File
@@ -14,3 +14,7 @@ dev = [
"psycopg2-binary==2.9.9",
"pytest==7.4.4",
]
[tool.isort]
profile = "black"
src_paths = ["airflow", "tests"]
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
set -euo pipefail
# Скрипт автоматизированного прогона E2E-теста всей цепочки DWH через REST API Airflow.
# Отрабатывает 2 "учебных дня" для проверки инкрементальной загрузки.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
warn() { echo ":: ${1}" >&2; }
# Загружаем переменные из .env, если файл существует, иначе используем дефолты
if [ -f ".env" ]; then
export $(grep -v '^#' .env | xargs)
fi
AIRFLOW_USER=${AIRFLOW_USER:-admin}
AIRFLOW_PASSWORD=${AIRFLOW_PASSWORD:-admin}
API_URL="http://localhost:8080/api/v1/dags"
# Функция для триггера DAG и ожидания его завершения
trigger_and_wait() {
local dag_id=$1
warn "Запуск $dag_id через REST API..."
# 0. Unpause DAG
curl -s -X PATCH "$API_URL/$dag_id" \
--user "$AIRFLOW_USER:$AIRFLOW_PASSWORD" \
-H "Content-Type: application/json" \
-d '{"is_paused": false}' > /dev/null
# 1. Trigger
local response
response=$(curl -s -X POST "$API_URL/$dag_id/dagRuns" \
--user "$AIRFLOW_USER:$AIRFLOW_PASSWORD" \
-H "Content-Type: application/json" \
-d '{}')
local run_id
run_id=$(echo "$response" | grep -o '"dag_run_id": "[^"]*' | cut -d'"' -f4)
if [ -z "$run_id" ]; then
echo "Ошибка запуска $dag_id. Ответ API: $response" >&2
exit 1
fi
warn "Успешный триггер. Run ID: $run_id"
# 2. Wait
warn "Ожидание завершения $dag_id..."
local attempts=0
local max_attempts=120 # 10 минут (120 * 5 сек)
while true; do
local status
status=$(curl -s "$API_URL/$dag_id/dagRuns/$run_id" \
--user "$AIRFLOW_USER:$AIRFLOW_PASSWORD" | grep -o '"state": "[^"]*' | cut -d'"' -f4)
if [ "$status" = "success" ]; then
warn "DAG $dag_id завершен: SUCCESS"
break
elif [ "$status" = "failed" ]; then
echo "DAG $dag_id УПАЛ (FAILED)!" >&2
exit 1
elif [ "$status" = "queued" ] || [ "$status" = "running" ] || [ "$status" = "" ]; then
attempts=$((attempts + 1))
if [ "$attempts" -ge "$max_attempts" ]; then
echo "Таймаут ожидания $dag_id!" >&2
exit 1
fi
sleep 5
else
echo "Неизвестный статус: $status" >&2
exit 1
fi
done
}
warn "=== DDL: Создание схем и таблиц ==="
trigger_and_wait "bookings_stg_ddl"
trigger_and_wait "bookings_ods_ddl"
trigger_and_wait "bookings_dds_ddl"
trigger_and_wait "bookings_dm_ddl"
warn "=== DAY 1: Initial Load ==="
trigger_and_wait "bookings_to_gp_stage"
trigger_and_wait "bookings_to_gp_ods"
trigger_and_wait "bookings_to_gp_dds"
trigger_and_wait "bookings_to_gp_dm"
warn "=== DAY 2: Increment ==="
trigger_and_wait "bookings_to_gp_stage"
trigger_and_wait "bookings_to_gp_ods"
trigger_and_wait "bookings_to_gp_dds"
trigger_and_wait "bookings_to_gp_dm"
warn "=== Верификация данных ==="
# Выполняем проверки строк и бизнес-логики, чтобы убедиться, что все витрины DM-слоя заполнены
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c \"psql -d gp_dwh -c \\\"
SELECT 'STG bookings' as layer, COUNT(*) FROM stg.bookings UNION ALL
SELECT 'ODS bookings', COUNT(*) FROM ods.bookings UNION ALL
SELECT 'DDS fact', COUNT(*) FROM dds.fact_flight_sales UNION ALL
SELECT 'DM sales_report', COUNT(*) FROM dm.sales_report UNION ALL
SELECT 'DM route_performance', COUNT(*) FROM dm.route_performance UNION ALL
SELECT 'DM passenger_loyalty', COUNT(*) FROM dm.passenger_loyalty UNION ALL
SELECT 'DM airport_traffic', COUNT(*) FROM dm.airport_traffic UNION ALL
SELECT 'DM monthly_overview', COUNT(*) FROM dm.monthly_overview;
-- Дополнительная проверка бизнес-логики (load factor не должен быть NULL и должен быть в пределах разумного)
SELECT
'Check LF' as check,
COUNT(*) as total_rows,
SUM(CASE WHEN avg_load_factor IS NOT NULL AND avg_load_factor >= 0 THEN 1 ELSE 0 END) as valid_lf
FROM dm.monthly_overview;
\\\"\""
warn "E2E ETL тест успешно завершен!"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
# Полный smoke-тест стенда: сносит volumes, поднимает стек,
# и проверяет оба учебных DAG через airflow dags test.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
warn() { echo ":: ${1}" >&2; }
wait_up() {
local svc="$1"
local attempts=60
while true; do
if docker compose -f docker-compose.yml ps "$svc" 2>/dev/null | grep -q "Up"; then
break
fi
attempts=$((attempts - 1))
if [ "$attempts" -le 0 ]; then
echo "Service $svc is not up after waiting" >&2
exit 1
fi
sleep 2
done
}
warn "Reset stack (containers + volumes)"
make clean
warn "Starting stack (docker compose up)"
make up
warn "Waiting for Airflow services"
wait_up airflow-webserver
wait_up airflow-scheduler
warn "Init demo DB bookings"
make bookings-init
warn "Run local pytest suite"
make test
warn "Start full E2E ETL test via REST API"
make e2e-etl
warn "Smoke test completed successfully"
+62 -11
View File
@@ -1,12 +1,63 @@
-- Greenplum DDL (GPDB 6 совместимо)
-- Колонночная таблица (append-optimized) и распределение по ключу.
-- Внимание: append-optimized таблицы не поддерживают UNIQUE/PRIMARY KEY,
-- поэтому контроль дублей выполняем в DAG при загрузке.
CREATE TABLE IF NOT EXISTS public.orders (
order_id BIGINT,
order_ts TIMESTAMP NOT NULL,
customer_id BIGINT NOT NULL,
amount NUMERIC(12,2) NOT NULL
-- Главный входной DDL-скрипт для Greenplum в учебном стенде.
-- Выполняется из контейнера командой `make ddl-gp` и создаёт/обновляет
-- все объекты, которые нужны DAG'ам bookings ETL-пайплайна;
-- подключает файловые DDL через \i, чтобы сохранять единый входной скрипт.
--
-- Чтобы не ломать задания, новые объекты лучше добавлять в отдельные файлы
-- и подключать их отсюда, а существующие определения не удалять.
--
-- Подробнее про STG/bookings: см. docs/bookings_to_gp_stage.md.
-- Внешняя таблица для чтения данных из демо-БД bookings через PXF (JDBC).
-- Источник: таблица bookings.bookings в базе demo (Postgres, сервис bookings-db).
DROP EXTERNAL TABLE IF EXISTS public.ext_bookings_bookings;
CREATE EXTERNAL TABLE public.ext_bookings_bookings (
book_ref CHAR(6),
book_date TIMESTAMP,
total_amount NUMERIC(10,2)
)
WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1)
DISTRIBUTED BY (order_id);
LOCATION ('pxf://bookings.bookings?PROFILE=JDBC&SERVER=bookings-db')
FORMAT 'CUSTOM' (formatter='pxfwritable_import');
-- DDL для слоя stg по таблицам bookings и tickets вынесены в отдельные файлы.
-- Здесь подключаем их через psql \i, чтобы сохранить единый входной скрипт.
\i stg/bookings_ddl.sql
\i stg/tickets_ddl.sql
-- DDL для новых таблиц STG слоя (справочники)
\i stg/airports_ddl.sql
\i stg/airplanes_ddl.sql
\i stg/routes_ddl.sql
\i stg/seats_ddl.sql
-- DDL для новых таблиц STG слоя (транзакции)
\i stg/flights_ddl.sql
\i stg/segments_ddl.sql
\i stg/boarding_passes_ddl.sql
-- DDL для ODS-слоя (текущее состояние, SCD1).
\i ods/airports_ddl.sql
\i ods/airplanes_ddl.sql
\i ods/routes_ddl.sql
\i ods/seats_ddl.sql
\i ods/bookings_ddl.sql
\i ods/tickets_ddl.sql
\i ods/flights_ddl.sql
\i ods/segments_ddl.sql
\i ods/boarding_passes_ddl.sql
-- DDL для DDS-слоя (Star Schema, SCD1 + SCD2).
\i dds/dim_calendar_ddl.sql
\i dds/dim_airports_ddl.sql
\i dds/dim_airplanes_ddl.sql
\i dds/dim_tariffs_ddl.sql
\i dds/dim_passengers_ddl.sql
\i dds/dim_routes_ddl.sql
\i dds/fact_flight_sales_ddl.sql
-- DDL для DM-слоя (Data Mart).
\i dm/sales_report_ddl.sql
\i dm/route_performance_ddl.sql
\i dm/passenger_loyalty_ddl.sql
\i dm/airport_traffic_ddl.sql
\i dm/monthly_overview_ddl.sql
+23
View File
@@ -0,0 +1,23 @@
-- DDL для DDS-слоя по таблице dim_airplanes (SCD1-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
-- Тип таблицы: Heap (стандартная).
-- Обоснование: Необходим row-level UPDATE для реализации SCD1 UPSERT.
-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы.
CREATE TABLE IF NOT EXISTS dds.dim_airplanes (
airplane_sk INTEGER NOT NULL,
airplane_bk TEXT NOT NULL,
model TEXT NOT NULL,
range_km INTEGER,
speed_kmh INTEGER,
total_seats INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
_load_id TEXT NOT NULL,
_load_ts TIMESTAMP NOT NULL DEFAULT now()
)
WITH (appendonly=false)
DISTRIBUTED BY (airplane_sk);
COMMENT ON TABLE dds.dim_airplanes IS 'Измерение моделей самолетов (DDS).';
+83
View File
@@ -0,0 +1,83 @@
-- DQ для DDS dim_airplanes.
DO $$
DECLARE
v_row_count BIGINT;
v_dup_sk BIGINT;
v_dup_bk BIGINT;
v_missing_bk BIGINT;
v_null_count BIGINT;
BEGIN
-- Таблица не пуста.
SELECT COUNT(*)
INTO v_row_count
FROM dds.dim_airplanes;
IF v_row_count = 0 THEN
RAISE EXCEPTION 'DQ FAILED: dds.dim_airplanes пуста.';
END IF;
-- Нет дублей по SK.
SELECT COUNT(*) - COUNT(DISTINCT airplane_sk)
INTO v_dup_sk
FROM dds.dim_airplanes;
IF v_dup_sk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airplanes найдены дубликаты airplane_sk: %',
v_dup_sk;
END IF;
-- Нет дублей по BK.
SELECT COUNT(*) - COUNT(DISTINCT airplane_bk)
INTO v_dup_bk
FROM dds.dim_airplanes;
IF v_dup_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airplanes найдены дубликаты airplane_bk: %',
v_dup_bk;
END IF;
-- Покрытие ODS: все airplane_code из ODS есть в DDS.
SELECT COUNT(*)
INTO v_missing_bk
FROM (SELECT DISTINCT airplane_code FROM ods.airplanes) AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_airplanes AS d
WHERE d.airplane_bk = s.airplane_code
);
IF v_missing_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airplanes отсутствуют ключи из ods.airplanes: %',
v_missing_bk;
END IF;
-- Обязательные поля.
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_airplanes
WHERE airplane_sk IS NULL
OR airplane_bk IS NULL
OR airplane_bk = ''
OR model IS NULL
OR model = ''
OR total_seats IS NULL
OR created_at IS NULL
OR updated_at IS NULL
OR _load_id IS NULL
OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airplanes найдены NULL/пустые обязательные поля: %',
v_null_count;
END IF;
RAISE NOTICE
'DQ PASSED: dds.dim_airplanes ок, строк=%',
v_row_count;
END $$;
+96
View File
@@ -0,0 +1,96 @@
-- Загрузка DDS dim_airplanes: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Statement 1: UPDATE существующих записей (если атрибуты изменились).
WITH seats_agg AS (
SELECT
s.airplane_code,
COUNT(*)::INTEGER AS total_seats
FROM ods.seats AS s
GROUP BY s.airplane_code
),
src AS (
SELECT
a.airplane_code,
a.model,
a.range_km,
a.speed_kmh,
COALESCE(sa.total_seats, 0) AS total_seats
FROM ods.airplanes AS a
LEFT JOIN seats_agg AS sa
ON sa.airplane_code = a.airplane_code
)
UPDATE dds.dim_airplanes AS d
SET model = s.model,
range_km = s.range_km,
speed_kmh = s.speed_kmh,
total_seats = s.total_seats,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM src AS s
WHERE d.airplane_bk = s.airplane_code
AND (
d.model IS DISTINCT FROM s.model
OR d.range_km IS DISTINCT FROM s.range_km
OR d.speed_kmh IS DISTINCT FROM s.speed_kmh
OR d.total_seats IS DISTINCT FROM s.total_seats
);
-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()).
WITH seats_agg AS (
SELECT
s.airplane_code,
COUNT(*)::INTEGER AS total_seats
FROM ods.seats AS s
GROUP BY s.airplane_code
),
src AS (
SELECT
a.airplane_code,
a.model,
a.range_km,
a.speed_kmh,
COALESCE(sa.total_seats, 0) AS total_seats
FROM ods.airplanes AS a
LEFT JOIN seats_agg AS sa
ON sa.airplane_code = a.airplane_code
),
-- Учебный комментарий: Генерация SK через MAX() + ROW_NUMBER()
-- Этот подход работает безопасно только потому, что Airflow запускает
-- джобы загрузки для одной таблицы строго последовательно (concurrency=1).
-- При параллельной загрузке возникнет состояние гонки (race condition) и возможны дубли SK.
max_sk AS (
SELECT COALESCE(MAX(airplane_sk), 0) AS v
FROM dds.dim_airplanes
)
INSERT INTO dds.dim_airplanes (
airplane_sk,
airplane_bk,
model,
range_km,
speed_kmh,
total_seats,
created_at,
updated_at,
_load_id,
_load_ts
)
SELECT
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.airplane_code)::INTEGER,
s.airplane_code,
s.model,
s.range_km,
s.speed_kmh,
s.total_seats,
now(),
now(),
'{{ run_id }}',
now()
FROM src AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_airplanes AS d
WHERE d.airplane_bk = s.airplane_code
);
ANALYZE dds.dim_airplanes;
+24
View File
@@ -0,0 +1,24 @@
-- DDL для DDS-слоя по таблице dim_airports (SCD1-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
-- Тип таблицы: Heap (стандартная).
-- Обоснование: Необходим row-level UPDATE для реализации SCD1 UPSERT.
-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы.
CREATE TABLE IF NOT EXISTS dds.dim_airports (
airport_sk INTEGER NOT NULL,
airport_bk TEXT NOT NULL,
airport_name TEXT NOT NULL,
city TEXT NOT NULL,
country TEXT NOT NULL,
timezone TEXT NOT NULL,
coordinates TEXT,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
_load_id TEXT NOT NULL,
_load_ts TIMESTAMP NOT NULL DEFAULT now()
)
WITH (appendonly=false)
DISTRIBUTED BY (airport_sk);
COMMENT ON TABLE dds.dim_airports IS 'Измерение аэропортов (DDS).';
+88
View File
@@ -0,0 +1,88 @@
-- DQ для DDS dim_airports.
DO $$
DECLARE
v_row_count BIGINT;
v_dup_sk BIGINT;
v_dup_bk BIGINT;
v_missing_bk BIGINT;
v_null_count BIGINT;
BEGIN
-- Таблица не пуста.
SELECT COUNT(*)
INTO v_row_count
FROM dds.dim_airports;
IF v_row_count = 0 THEN
RAISE EXCEPTION 'DQ FAILED: dds.dim_airports пуста.';
END IF;
-- Нет дублей по SK.
SELECT COUNT(*) - COUNT(DISTINCT airport_sk)
INTO v_dup_sk
FROM dds.dim_airports;
IF v_dup_sk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airports найдены дубликаты airport_sk: %',
v_dup_sk;
END IF;
-- Нет дублей по BK.
SELECT COUNT(*) - COUNT(DISTINCT airport_bk)
INTO v_dup_bk
FROM dds.dim_airports;
IF v_dup_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airports найдены дубликаты airport_bk: %',
v_dup_bk;
END IF;
-- Покрытие ODS: все airport_code из ODS есть в DDS.
SELECT COUNT(*)
INTO v_missing_bk
FROM (SELECT DISTINCT airport_code FROM ods.airports) AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_airports AS d
WHERE d.airport_bk = s.airport_code
);
IF v_missing_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airports отсутствуют ключи из ods.airports: %',
v_missing_bk;
END IF;
-- Обязательные поля.
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_airports
WHERE airport_sk IS NULL
OR airport_bk IS NULL
OR airport_bk = ''
OR airport_name IS NULL
OR airport_name = ''
OR city IS NULL
OR city = ''
OR country IS NULL
OR country = ''
OR timezone IS NULL
OR timezone = ''
OR created_at IS NULL
OR updated_at IS NULL
OR _load_id IS NULL
OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airports найдены NULL/пустые обязательные поля: %',
v_null_count;
END IF;
RAISE NOTICE
'DQ PASSED: dds.dim_airports ок, строк=%',
v_row_count;
END $$;
+67
View File
@@ -0,0 +1,67 @@
-- Загрузка DDS dim_airports: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Statement 1: UPDATE существующих записей (если атрибуты изменились).
UPDATE dds.dim_airports AS d
SET airport_name = s.airport_name,
city = s.city,
country = s.country,
timezone = s.timezone,
coordinates = s.coordinates,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM ods.airports AS s
WHERE d.airport_bk = s.airport_code
AND (
d.airport_name IS DISTINCT FROM s.airport_name
OR d.city IS DISTINCT FROM s.city
OR d.country IS DISTINCT FROM s.country
OR d.timezone IS DISTINCT FROM s.timezone
OR d.coordinates IS DISTINCT FROM s.coordinates
);
-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()).
-- Учебный комментарий: Генерация SK через MAX() + ROW_NUMBER()
-- Почему не SERIAL/IDENTITY? В MPP-базах данных (как Greenplum) sequence
-- работают через мастер-узел и могут стать узким местом при массовой вставке.
-- Паттерн MAX() + ROW_NUMBER() генерирует ключи распределённо на сегментах.
-- Этот подход работает безопасно только потому, что Airflow запускает
-- джобы загрузки для одной таблицы строго последовательно (concurrency=1).
-- При параллельной загрузке возникнет состояние гонки (race condition) и возможны дубли SK.
WITH max_sk AS (
SELECT COALESCE(MAX(airport_sk), 0) AS v
FROM dds.dim_airports
)
INSERT INTO dds.dim_airports (
airport_sk,
airport_bk,
airport_name,
city,
country,
timezone,
coordinates,
created_at,
updated_at,
_load_id,
_load_ts
)
SELECT
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.airport_code)::INTEGER,
s.airport_code,
s.airport_name,
s.city,
s.country,
s.timezone,
s.coordinates,
now(),
now(),
'{{ run_id }}',
now()
FROM ods.airports AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_airports AS d
WHERE d.airport_bk = s.airport_code
);
ANALYZE dds.dim_airports;
+20
View File
@@ -0,0 +1,20 @@
-- DDL для DDS-слоя по таблице dim_calendar (статическое измерение дат).
CREATE SCHEMA IF NOT EXISTS dds;
-- Тип таблицы: Append-Only Row-oriented (zstd:1).
-- Обоснование: Статичные данные без обновлений. Обеспечивает эффективное сжатие.
CREATE TABLE IF NOT EXISTS dds.dim_calendar (
calendar_sk INTEGER NOT NULL,
date_actual DATE NOT NULL,
year_actual INTEGER NOT NULL,
month_actual INTEGER NOT NULL,
day_actual INTEGER NOT NULL,
day_of_week INTEGER NOT NULL,
day_name TEXT NOT NULL,
is_weekend BOOLEAN NOT NULL
)
WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1)
DISTRIBUTED BY (calendar_sk);
COMMENT ON TABLE dds.dim_calendar IS 'Измерение календаря (DDS).';
+87
View File
@@ -0,0 +1,87 @@
-- DQ для DDS dim_calendar.
DO $$
DECLARE
v_row_count BIGINT;
v_dup_sk BIGINT;
v_dup_date BIGINT;
v_null_count BIGINT;
v_missing_flight_dates BIGINT;
BEGIN
-- Таблица должна быть достаточно заполнена.
SELECT COUNT(*)
INTO v_row_count
FROM dds.dim_calendar;
IF v_row_count < 1000 THEN
RAISE EXCEPTION
'DQ FAILED: dds.dim_calendar содержит слишком мало строк: %',
v_row_count;
END IF;
-- Нет дублей по surrogate key.
SELECT COUNT(*) - COUNT(DISTINCT calendar_sk)
INTO v_dup_sk
FROM dds.dim_calendar;
IF v_dup_sk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_calendar найдены дубликаты calendar_sk: %',
v_dup_sk;
END IF;
-- Нет дублей по business key (date_actual).
SELECT COUNT(*) - COUNT(DISTINCT date_actual)
INTO v_dup_date
FROM dds.dim_calendar;
IF v_dup_date <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_calendar найдены дубликаты date_actual: %',
v_dup_date;
END IF;
-- Обязательные поля не NULL.
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_calendar
WHERE calendar_sk IS NULL
OR date_actual IS NULL
OR year_actual IS NULL
OR month_actual IS NULL
OR day_actual IS NULL
OR day_of_week IS NULL
OR day_name IS NULL
OR day_name = ''
OR is_weekend IS NULL;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_calendar найдены NULL/пустые обязательные поля: %',
v_null_count;
END IF;
-- Календарь покрывает даты вылета из ODS (где scheduled_departure не NULL).
SELECT COUNT(*)
INTO v_missing_flight_dates
FROM (
SELECT DISTINCT f.scheduled_departure::DATE AS departure_date
FROM ods.flights AS f
WHERE f.scheduled_departure IS NOT NULL
) AS src
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_calendar AS c
WHERE c.date_actual = src.departure_date
);
IF v_missing_flight_dates <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: dds.dim_calendar не покрывает даты вылета из ods.flights: %',
v_missing_flight_dates;
END IF;
RAISE NOTICE
'DQ PASSED: dds.dim_calendar ок, строк=%',
v_row_count;
END $$;
+29
View File
@@ -0,0 +1,29 @@
-- Загрузка DDS dim_calendar: статическое измерение (генерация дат).
-- Заполняем только если таблица пуста (идемпотентно).
INSERT INTO dds.dim_calendar (
calendar_sk,
date_actual,
year_actual,
month_actual,
day_actual,
day_of_week,
day_name,
is_weekend
)
SELECT
ROW_NUMBER() OVER (ORDER BY d.date_actual)::INTEGER AS calendar_sk,
d.date_actual,
EXTRACT(YEAR FROM d.date_actual)::INTEGER AS year_actual,
EXTRACT(MONTH FROM d.date_actual)::INTEGER AS month_actual,
EXTRACT(DAY FROM d.date_actual)::INTEGER AS day_actual,
EXTRACT(ISODOW FROM d.date_actual)::INTEGER AS day_of_week,
TO_CHAR(d.date_actual, 'FMDay') AS day_name,
EXTRACT(ISODOW FROM d.date_actual) IN (6, 7) AS is_weekend
FROM (
SELECT generate_series('2016-01-01'::DATE, '2030-12-31'::DATE, '1 day'::INTERVAL)::DATE
AS date_actual
) AS d
WHERE NOT EXISTS (SELECT 1 FROM dds.dim_calendar LIMIT 1);
ANALYZE dds.dim_calendar;
+20
View File
@@ -0,0 +1,20 @@
-- DDL для DDS-слоя по таблице dim_passengers (SCD1-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
-- Тип таблицы: Heap (стандартная).
-- Обоснование: Необходим row-level UPDATE для реализации SCD1 UPSERT.
-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы.
CREATE TABLE IF NOT EXISTS dds.dim_passengers (
passenger_sk INTEGER NOT NULL,
passenger_id TEXT NOT NULL,
passenger_name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
_load_id TEXT NOT NULL,
_load_ts TIMESTAMP NOT NULL DEFAULT now()
)
WITH (appendonly=false)
DISTRIBUTED BY (passenger_sk);
COMMENT ON TABLE dds.dim_passengers IS 'Измерение пассажиров (DDS).';
+100
View File
@@ -0,0 +1,100 @@
-- DQ для DDS dim_passengers.
DO $$
DECLARE
v_row_count BIGINT;
v_src_count BIGINT;
v_dup_sk BIGINT;
v_dup_bk BIGINT;
v_missing_bk BIGINT;
v_null_count BIGINT;
BEGIN
-- Для инкрементальных периодов без новых билетов допускаем пустую dim_passengers.
SELECT COUNT(*)
INTO v_row_count
FROM dds.dim_passengers;
SELECT COUNT(DISTINCT passenger_id)
INTO v_src_count
FROM ods.tickets
WHERE passenger_id IS NOT NULL
AND passenger_id <> '';
IF v_row_count = 0 AND v_src_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: dds.dim_passengers пуста при непустом источнике ods.tickets (passenger_id=%).',
v_src_count;
ELSIF v_row_count = 0 AND v_src_count = 0 THEN
RAISE NOTICE
'DQ PASSED: dds.dim_passengers пуста, т.к. в ods.tickets нет passenger_id для загрузки.';
RETURN;
END IF;
-- Нет дублей по SK.
SELECT COUNT(*) - COUNT(DISTINCT passenger_sk)
INTO v_dup_sk
FROM dds.dim_passengers;
IF v_dup_sk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_passengers найдены дубликаты passenger_sk: %',
v_dup_sk;
END IF;
-- Нет дублей по BK (ID).
SELECT COUNT(*) - COUNT(DISTINCT passenger_id)
INTO v_dup_bk
FROM dds.dim_passengers;
IF v_dup_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_passengers найдены дубликаты passenger_id: %',
v_dup_bk;
END IF;
-- Покрытие ODS: все passenger_id из ods.tickets есть в DDS.
SELECT COUNT(*)
INTO v_missing_bk
FROM (
SELECT DISTINCT passenger_id
FROM ods.tickets
WHERE passenger_id IS NOT NULL
AND passenger_id <> ''
) AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_passengers AS d
WHERE d.passenger_id = s.passenger_id
);
IF v_missing_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_passengers отсутствуют passenger_id из ods.tickets: %',
v_missing_bk;
END IF;
-- Обязательные поля.
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_passengers
WHERE passenger_sk IS NULL
OR passenger_id IS NULL
OR passenger_id = ''
OR passenger_name IS NULL
OR passenger_name = ''
OR created_at IS NULL
OR updated_at IS NULL
OR _load_id IS NULL
OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_passengers найдены NULL/пустые обязательные поля: %',
v_null_count;
END IF;
RAISE NOTICE
'DQ PASSED: dds.dim_passengers ок, строк=%',
v_row_count;
END $$;
+67
View File
@@ -0,0 +1,67 @@
-- Загрузка DDS dim_passengers: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Учебный комментарий: Используем TEMP TABLE для подготовки дельты.
-- Это избавляет от дублирования сложной оконной функции в UPDATE и INSERT блоках.
CREATE TEMP TABLE tmp_passengers_src ON COMMIT DROP AS
SELECT
d.passenger_id,
d.passenger_name
FROM (
SELECT
t.passenger_id,
t.passenger_name,
ROW_NUMBER() OVER (
PARTITION BY t.passenger_id
ORDER BY t.event_ts DESC NULLS LAST, t._load_ts DESC, t.ticket_no DESC
) AS rn
FROM ods.tickets AS t
WHERE t.passenger_id IS NOT NULL
AND t.passenger_id <> ''
AND t.passenger_name IS NOT NULL
AND t.passenger_name <> ''
) AS d
WHERE d.rn = 1;
-- Statement 1: UPDATE существующих записей (если атрибуты изменились).
UPDATE dds.dim_passengers AS d
SET passenger_name = s.passenger_name,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM tmp_passengers_src AS s
WHERE d.passenger_id = s.passenger_id
AND d.passenger_name IS DISTINCT FROM s.passenger_name;
-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()).
WITH max_sk AS (
-- Учебный комментарий: Генерация SK через MAX() + ROW_NUMBER()
-- Этот подход работает безопасно только потому, что Airflow запускает
-- джобы загрузки для одной таблицы строго последовательно (concurrency=1).
SELECT COALESCE(MAX(passenger_sk), 0) AS v
FROM dds.dim_passengers
)
INSERT INTO dds.dim_passengers (
passenger_sk,
passenger_id,
passenger_name,
created_at,
updated_at,
_load_id,
_load_ts
)
SELECT
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.passenger_id)::INTEGER,
s.passenger_id,
s.passenger_name,
now(),
now(),
'{{ run_id }}',
now()
FROM tmp_passengers_src AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_passengers AS d
WHERE d.passenger_id = s.passenger_id
);
ANALYZE dds.dim_passengers;
+40
View File
@@ -0,0 +1,40 @@
-- DDL для DDS-слоя по таблице dim_routes (SCD2-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
-- Тип таблицы: Heap (стандартная).
-- Обоснование: Необходим row-level UPDATE для реализации SCD2 (закрытие версий).
-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы.
--
-- Учебный комментарий (Kimball Star Schema):
-- Измерение должно быть «самодостаточным»: один JOIN к dim_routes —
-- и аналитик видит маршрут, города, модель самолёта и кол-во мест.
-- Денормализованные атрибуты (departure_city, arrival_city, airplane_model, total_seats)
-- НЕ участвуют в hashdiff. Версия SCD2 фиксирует изменения атрибутов маршрута
-- (аэропорт, самолёт, расписание). Если изменится название города —
-- обновим отдельным refresh-шагом, не создавая новую версию.
CREATE TABLE IF NOT EXISTS dds.dim_routes (
route_sk INTEGER NOT NULL,
route_bk TEXT NOT NULL,
departure_airport TEXT NOT NULL,
arrival_airport TEXT NOT NULL,
airplane_code TEXT NOT NULL,
departure_city TEXT NOT NULL,
arrival_city TEXT NOT NULL,
airplane_model TEXT NOT NULL,
total_seats INTEGER NOT NULL,
days_of_week TEXT,
departure_time TIME,
duration INTERVAL,
hashdiff TEXT NOT NULL,
valid_from DATE NOT NULL,
valid_to DATE,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
_load_id TEXT NOT NULL,
_load_ts TIMESTAMP NOT NULL DEFAULT now()
)
WITH (appendonly=false)
DISTRIBUTED BY (route_sk);
COMMENT ON TABLE dds.dim_routes IS 'Измерение маршрутов (DDS).';
+157
View File
@@ -0,0 +1,157 @@
-- DQ для DDS dim_routes (SCD2).
DO $$
DECLARE
v_row_count BIGINT;
v_dup_sk BIGINT;
v_dup_current BIGINT;
v_overlap_count BIGINT;
v_missing_count BIGINT;
v_orphan_current BIGINT;
v_null_count BIGINT;
BEGIN
-- Таблица не пуста.
SELECT COUNT(*)
INTO v_row_count
FROM dds.dim_routes;
IF v_row_count = 0 THEN
RAISE EXCEPTION 'DQ FAILED: dds.dim_routes пуста.';
END IF;
-- Нет дублей по SK.
SELECT COUNT(*) - COUNT(DISTINCT route_sk)
INTO v_dup_sk
FROM dds.dim_routes;
IF v_dup_sk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены дубликаты route_sk: %',
v_dup_sk;
END IF;
-- Корректность интервалов (valid_from <= valid_to для закрытых версий).
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_routes
WHERE valid_to IS NOT NULL
AND valid_from > valid_to;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены версии с valid_from > valid_to: %',
v_null_count;
END IF;
-- Нет перекрытий интервалов для одного route_bk.
SELECT COUNT(*)
INTO v_overlap_count
FROM (
SELECT 1
FROM dds.dim_routes AS d1
JOIN dds.dim_routes AS d2
ON d1.route_bk = d2.route_bk
AND d1.route_sk < d2.route_sk
AND d1.valid_from < COALESCE(d2.valid_to, DATE '9999-12-31')
AND d2.valid_from < COALESCE(d1.valid_to, DATE '9999-12-31')
) AS overlap_rows;
IF v_overlap_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены перекрытия SCD2-интервалов: %',
v_overlap_count;
END IF;
-- Не более одной текущей версии на route_bk.
SELECT COUNT(*)
INTO v_dup_current
FROM (
SELECT route_bk
FROM dds.dim_routes
WHERE valid_to IS NULL
GROUP BY route_bk
HAVING COUNT(*) > 1
) AS d;
IF v_dup_current <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены route_bk с > 1 текущей версией: %',
v_dup_current;
END IF;
-- Покрытие ODS: все route_no имеют хотя бы одну версию в DDS.
SELECT COUNT(*)
INTO v_missing_count
FROM (SELECT DISTINCT route_no FROM ods.routes) AS o
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_routes AS d
WHERE d.route_bk = o.route_no
);
IF v_missing_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes отсутствуют маршруты из ODS: %',
v_missing_count;
END IF;
-- Current-срез DDS не содержит route_bk, которых нет в ODS.
SELECT COUNT(*)
INTO v_orphan_current
FROM (
SELECT DISTINCT route_bk
FROM dds.dim_routes
WHERE valid_to IS NULL
) AS d
WHERE NOT EXISTS (
SELECT 1
FROM ods.routes AS o
WHERE o.route_no = d.route_bk
);
IF v_orphan_current <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в current-срезе dds.dim_routes есть route_bk вне ODS: %',
v_orphan_current;
END IF;
-- Обязательные поля.
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_routes
WHERE route_sk IS NULL
OR route_bk IS NULL
OR route_bk = ''
OR departure_airport IS NULL
OR departure_airport = ''
OR arrival_airport IS NULL
OR arrival_airport = ''
OR airplane_code IS NULL
OR airplane_code = ''
OR departure_city IS NULL
OR departure_city = ''
OR arrival_city IS NULL
OR arrival_city = ''
OR airplane_model IS NULL
OR airplane_model = ''
OR total_seats IS NULL
OR hashdiff IS NULL
OR hashdiff = ''
OR valid_from IS NULL
OR created_at IS NULL
OR updated_at IS NULL
OR _load_id IS NULL
OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены NULL обязательные поля: %',
v_null_count;
END IF;
RAISE NOTICE
'DQ PASSED: dds.dim_routes ок, строк=% (версий)',
v_row_count;
END $$;
+144
View File
@@ -0,0 +1,144 @@
-- Загрузка DDS dim_routes: SCD2 с hashdiff.
-- Учебный комментарий: Используем TEMP TABLE для подготовки дельты.
-- Это избавляет от дублирования логики hashdiff в UPDATE и INSERT блоках.
CREATE TEMP TABLE tmp_routes_src ON COMMIT DROP AS
SELECT
route_no,
departure_airport,
arrival_airport,
airplane_code,
days_of_week,
departure_time,
duration,
md5(
COALESCE(departure_airport, '') || '|' ||
COALESCE(arrival_airport, '') || '|' ||
COALESCE(airplane_code, '') || '|' ||
COALESCE(days_of_week::TEXT, '') || '|' ||
COALESCE(departure_time::TEXT, '') || '|' ||
COALESCE(duration::TEXT, '')
) AS hashdiff,
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
FROM ods.routes;
-- Statement 1: Закрыть устаревшие версии (valid_to = текущая дата).
UPDATE dds.dim_routes AS d
SET valid_to = CURRENT_DATE,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM tmp_routes_src AS s
WHERE s.rn = 1
AND d.route_bk = s.route_no
AND d.valid_to IS NULL
AND d.hashdiff <> s.hashdiff;
-- Statement 1.1: Закрыть "исчезнувшие" маршруты.
UPDATE dds.dim_routes AS d
SET valid_to = CURRENT_DATE,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
WHERE d.valid_to IS NULL
AND NOT EXISTS (
SELECT 1
FROM tmp_routes_src AS s
WHERE s.rn = 1
AND s.route_no = d.route_bk
);
-- Statement 2: Вставить новые версии (для изменённых и новых route_no).
WITH max_sk AS (
-- Учебный комментарий: Генерация SK через MAX() + ROW_NUMBER()
-- Этот подход работает безопасно только потому, что Airflow запускает
-- джобы загрузки для одной таблицы строго последовательно (concurrency=1).
SELECT COALESCE(MAX(route_sk), 0) AS v
FROM dds.dim_routes
)
INSERT INTO dds.dim_routes (
route_sk,
route_bk,
departure_airport,
arrival_airport,
airplane_code,
departure_city,
arrival_city,
airplane_model,
total_seats,
days_of_week,
departure_time,
duration,
hashdiff,
valid_from,
valid_to,
created_at,
updated_at,
_load_id,
_load_ts
)
SELECT
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.route_no)::INTEGER,
s.route_no,
s.departure_airport,
s.arrival_airport,
s.airplane_code,
dep.city,
arr.city,
air.model,
air.total_seats,
s.days_of_week,
s.departure_time,
s.duration,
s.hashdiff,
CASE
WHEN EXISTS (
SELECT 1
FROM dds.dim_routes AS d2
WHERE d2.route_bk = s.route_no
) THEN CURRENT_DATE
ELSE '1900-01-01'::DATE
END AS valid_from,
NULL,
now(),
now(),
'{{ run_id }}',
now()
FROM tmp_routes_src AS s
LEFT JOIN dds.dim_airports AS dep ON s.departure_airport = dep.airport_bk
LEFT JOIN dds.dim_airports AS arr ON s.arrival_airport = arr.airport_bk
LEFT JOIN dds.dim_airplanes AS air ON s.airplane_code = air.airplane_bk
WHERE s.rn = 1
AND NOT EXISTS (
SELECT 1
FROM dds.dim_routes AS d
WHERE d.route_bk = s.route_no
AND d.valid_to IS NULL
AND d.hashdiff = s.hashdiff
);
-- Фаза 3: Обновление денормализованных атрибутов (refresh).
-- Нужна для SCD1-изменений в dim_airports/dim_airplanes (напр. переименование города).
-- Обновляем все версии (и текущие, и исторические), т.к. измерения SCD1.
-- При этом мы не перезаписываем _load_id и _load_ts, чтобы не размывать lineage версий.
UPDATE dds.dim_routes AS d
SET departure_city = dep.city,
arrival_city = arr.city,
airplane_model = air.model,
total_seats = air.total_seats,
updated_at = now()
FROM dds.dim_airports AS dep,
dds.dim_airports AS arr,
dds.dim_airplanes AS air
WHERE dep.airport_bk = d.departure_airport
AND arr.airport_bk = d.arrival_airport
AND air.airplane_bk = d.airplane_code
AND (
d.departure_city IS DISTINCT FROM dep.city
OR d.arrival_city IS DISTINCT FROM arr.city
OR d.airplane_model IS DISTINCT FROM air.model
OR d.total_seats IS DISTINCT FROM air.total_seats
);
ANALYZE dds.dim_routes;
+18
View File
@@ -0,0 +1,18 @@
-- DDL для DDS-слоя по таблице dim_tariffs (SCD1-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
-- Тип таблицы: Append-Only Row-oriented (zstd:1).
-- Обоснование: Редко дополняемые данные без обновлений. Обеспечивает эффективное сжатие.
CREATE TABLE IF NOT EXISTS dds.dim_tariffs (
tariff_sk INTEGER NOT NULL,
fare_conditions TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
_load_id TEXT NOT NULL,
_load_ts TIMESTAMP NOT NULL DEFAULT now()
)
WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1)
DISTRIBUTED BY (tariff_sk);
COMMENT ON TABLE dds.dim_tariffs IS 'Измерение тарифов (DDS).';
+98
View File
@@ -0,0 +1,98 @@
-- DQ для DDS dim_tariffs.
DO $$
DECLARE
v_row_count BIGINT;
v_src_count BIGINT;
v_dup_sk BIGINT;
v_dup_bk BIGINT;
v_missing_bk BIGINT;
v_null_count BIGINT;
BEGIN
-- Для инкрементальных периодов без новых сегментов допускаем пустую dim_tariffs.
SELECT COUNT(*)
INTO v_row_count
FROM dds.dim_tariffs;
SELECT COUNT(DISTINCT fare_conditions)
INTO v_src_count
FROM ods.segments
WHERE fare_conditions IS NOT NULL
AND fare_conditions <> '';
IF v_row_count = 0 AND v_src_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: dds.dim_tariffs пуста при непустом источнике ods.segments (fare_conditions=%).',
v_src_count;
ELSIF v_row_count = 0 AND v_src_count = 0 THEN
RAISE NOTICE
'DQ PASSED: dds.dim_tariffs пуста, т.к. в ods.segments нет тарифов для загрузки.';
RETURN;
END IF;
-- Нет дублей по SK.
SELECT COUNT(*) - COUNT(DISTINCT tariff_sk)
INTO v_dup_sk
FROM dds.dim_tariffs;
IF v_dup_sk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_tariffs найдены дубликаты tariff_sk: %',
v_dup_sk;
END IF;
-- Нет дублей по BK.
SELECT COUNT(*) - COUNT(DISTINCT fare_conditions)
INTO v_dup_bk
FROM dds.dim_tariffs;
IF v_dup_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_tariffs найдены дубликаты fare_conditions: %',
v_dup_bk;
END IF;
-- Покрытие ODS: все fare_conditions из ods.segments есть в DDS.
SELECT COUNT(*)
INTO v_missing_bk
FROM (
SELECT DISTINCT fare_conditions
FROM ods.segments
WHERE fare_conditions IS NOT NULL
AND fare_conditions <> ''
) AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_tariffs AS d
WHERE d.fare_conditions = s.fare_conditions
);
IF v_missing_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_tariffs отсутствуют значения fare_conditions из ods.segments: %',
v_missing_bk;
END IF;
-- Обязательные поля.
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_tariffs
WHERE tariff_sk IS NULL
OR fare_conditions IS NULL
OR fare_conditions = ''
OR created_at IS NULL
OR updated_at IS NULL
OR _load_id IS NULL
OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_tariffs найдены NULL/пустые обязательные поля: %',
v_null_count;
END IF;
RAISE NOTICE
'DQ PASSED: dds.dim_tariffs ок, строк=%',
v_row_count;
END $$;
+40
View File
@@ -0,0 +1,40 @@
-- Загрузка DDS dim_tariffs: SCD1 UPSERT (INSERT новых тарифов).
WITH src AS (
SELECT DISTINCT
s.fare_conditions
FROM ods.segments AS s
WHERE s.fare_conditions IS NOT NULL
AND s.fare_conditions <> ''
),
-- Учебный комментарий: Генерация SK через MAX() + ROW_NUMBER()
-- Этот подход работает безопасно только потому, что Airflow запускает
-- джобы загрузки для одной таблицы строго последовательно (concurrency=1).
-- При параллельной загрузке возникнет состояние гонки (race condition) и возможны дубли SK.
max_sk AS (
SELECT COALESCE(MAX(tariff_sk), 0) AS v
FROM dds.dim_tariffs
)
INSERT INTO dds.dim_tariffs (
tariff_sk,
fare_conditions,
created_at,
updated_at,
_load_id,
_load_ts
)
SELECT
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.fare_conditions)::INTEGER,
s.fare_conditions,
now(),
now(),
'{{ run_id }}',
now()
FROM src AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_tariffs AS d
WHERE d.fare_conditions = s.fare_conditions
);
ANALYZE dds.dim_tariffs;
+34
View File
@@ -0,0 +1,34 @@
-- DDL для DDS-слоя по таблице fact_flight_sales.
CREATE SCHEMA IF NOT EXISTS dds;
-- Учебный комментарий: Почему у факта нет своего суррогатного ключа (fact_sk)?
-- В классическом DWH (Кимбалл) таблица фактов идентифицируется набором её
-- измерений или дегенеративных ключей (в нашем случае: ticket_no + flight_id).
-- Добавление отдельного ID только тратит место и не несёт аналитической ценности.
-- Тип таблицы: Heap (стандартная).
-- Обоснование: Необходим row-level UPDATE для обновления статусов (is_boarded).
-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы.
CREATE TABLE IF NOT EXISTS dds.fact_flight_sales (
calendar_sk INTEGER,
departure_airport_sk INTEGER,
arrival_airport_sk INTEGER,
airplane_sk INTEGER,
tariff_sk INTEGER,
passenger_sk INTEGER,
route_sk INTEGER,
book_ref TEXT NOT NULL,
ticket_no TEXT NOT NULL,
flight_id INTEGER NOT NULL,
book_date DATE,
seat_no TEXT,
price NUMERIC(10,2),
is_boarded BOOLEAN NOT NULL,
_load_id TEXT NOT NULL,
_load_ts TIMESTAMP NOT NULL DEFAULT now()
)
WITH (appendonly=false)
DISTRIBUTED BY (ticket_no);
COMMENT ON TABLE dds.fact_flight_sales IS 'Факт продаж билетов (DDS).';
+151
View File
@@ -0,0 +1,151 @@
-- DQ для DDS fact_flight_sales.
DO $$
DECLARE
v_row_count BIGINT;
v_ods_count BIGINT;
v_dup_count BIGINT;
v_null_passenger BIGINT;
v_null_tariff BIGINT;
v_null_route_related BIGINT;
v_null_calendar BIGINT;
v_null_required BIGINT;
BEGIN
-- Для пустого инкрементального окна (ods.segments) допускаем пустой факт.
SELECT COUNT(*)
INTO v_ods_count
FROM ods.segments;
SELECT COUNT(*)
INTO v_row_count
FROM dds.fact_flight_sales;
IF v_ods_count = 0 THEN
IF v_row_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: ods.segments пустая, но в dds.fact_flight_sales есть строки: %',
v_row_count;
END IF;
RAISE NOTICE
'DQ PASSED: ods.segments и dds.fact_flight_sales пустые (инкрементальное окно без сегментов).';
RETURN;
END IF;
IF v_row_count = 0 THEN
RAISE EXCEPTION
'DQ FAILED: dds.fact_flight_sales пуста при непустом источнике ods.segments (%).',
v_ods_count;
END IF;
-- Покрытие: количество строк = ods.segments.
IF v_row_count <> v_ods_count THEN
RAISE EXCEPTION
'DQ FAILED: dds.fact_flight_sales (%) <> ods.segments (%). Потеряны строки.',
v_row_count,
v_ods_count;
END IF;
-- Нет дублей по зерну.
SELECT COUNT(*)
INTO v_dup_count
FROM (
SELECT ticket_no, flight_id
FROM dds.fact_flight_sales
GROUP BY ticket_no, flight_id
HAVING COUNT(*) > 1
) AS d;
IF v_dup_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.fact_flight_sales дубликаты (ticket_no, flight_id): %',
v_dup_count;
END IF;
-- Ссылочная целостность: passenger_sk.
SELECT COUNT(*)
INTO v_null_passenger
FROM dds.fact_flight_sales
WHERE passenger_sk IS NULL;
IF v_null_passenger <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales строки без passenger_sk: %',
v_null_passenger;
END IF;
-- Ссылочная целостность: tariff_sk.
SELECT COUNT(*)
INTO v_null_tariff
FROM dds.fact_flight_sales
WHERE tariff_sk IS NULL;
IF v_null_tariff <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales строки без tariff_sk: %',
v_null_tariff;
END IF;
-- Route-related FK: допустимо при аномалиях, фейлим если > 1%.
SELECT COUNT(*)
INTO v_null_route_related
FROM dds.fact_flight_sales
WHERE route_sk IS NULL
OR departure_airport_sk IS NULL
OR arrival_airport_sk IS NULL
OR airplane_sk IS NULL;
IF v_null_route_related > 0 THEN
IF v_null_route_related * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales слишком много строк с NULL в route-related FK: % (>1%%)',
v_null_route_related;
ELSE
RAISE NOTICE
'DQ WARNING: в fact_flight_sales строк с NULL в route-related FK: % (<=1%%, допустимо)',
v_null_route_related;
END IF;
END IF;
-- Calendar: допустимо если scheduled_departure IS NULL, фейлим если > 1%.
SELECT COUNT(*)
INTO v_null_calendar
FROM dds.fact_flight_sales
WHERE calendar_sk IS NULL;
IF v_null_calendar > 0 THEN
IF v_null_calendar * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales слишком много строк без calendar_sk: % (>1%%)',
v_null_calendar;
ELSE
RAISE NOTICE
'DQ WARNING: в fact_flight_sales строк без calendar_sk: % (<=1%%, допустимо)',
v_null_calendar;
END IF;
END IF;
-- Обязательные поля.
SELECT COUNT(*)
INTO v_null_required
FROM dds.fact_flight_sales
WHERE book_ref IS NULL
OR book_ref = ''
OR ticket_no IS NULL
OR ticket_no = ''
OR flight_id IS NULL
OR is_boarded IS NULL
OR _load_id IS NULL
OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_required <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales NULL обязательные поля: %',
v_null_required;
END IF;
RAISE NOTICE
'DQ PASSED: dds.fact_flight_sales ок, строк=%',
v_row_count;
END $$;
+129
View File
@@ -0,0 +1,129 @@
-- Загрузка DDS fact_flight_sales: инкрементальный UPSERT по (ticket_no, flight_id).
-- Statement 1: UPDATE существующих строк факта.
-- Обновляем только мутабельные поля; SK измерений не перезаписываем.
UPDATE dds.fact_flight_sales AS f
SET seat_no = bp.seat_no,
price = seg.amount,
is_boarded = (bp.ticket_no IS NOT NULL),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM ods.segments AS seg
LEFT JOIN ods.boarding_passes AS bp
ON bp.ticket_no = seg.ticket_no
AND bp.flight_id = seg.flight_id
WHERE f.ticket_no = seg.ticket_no
AND f.flight_id = seg.flight_id
AND (
f.is_boarded IS DISTINCT FROM (bp.ticket_no IS NOT NULL)
OR f.price IS DISTINCT FROM seg.amount
OR f.seat_no IS DISTINCT FROM bp.seat_no
);
-- Statement 2: INSERT новых строк факта.
-- Dimension SK фиксируются на момент вставки (point-in-time для SCD2 routes).
WITH fact_src AS (
SELECT
seg.ticket_no,
seg.flight_id,
cal.calendar_sk,
dep.airport_sk AS departure_airport_sk,
arr.airport_sk AS arrival_airport_sk,
ap.airplane_sk,
tar.tariff_sk,
pax.passenger_sk,
rte.route_sk,
tkt.book_ref,
bkg.book_date::DATE AS book_date,
bp.seat_no,
seg.amount AS price,
(bp.ticket_no IS NOT NULL) AS is_boarded
FROM ods.segments AS seg
JOIN ods.tickets AS tkt
ON tkt.ticket_no = seg.ticket_no
JOIN ods.bookings AS bkg
ON bkg.book_ref = tkt.book_ref
JOIN ods.flights AS flt
ON flt.flight_id = seg.flight_id
-- Учебный комментарий: Late-arriving dimensions (Опаздывающие измерения)
-- Мы используем LEFT JOIN, так как факт (рейс/билет) может прийти раньше,
-- чем справочник (пассажир/маршрут) обновится в DDS.
--
-- Airport lookup через ods.routes (эталонный справочник), а не через dds.dim_routes
-- (студенческое задание SCD2). Это архитектурное решение: эталонный пайплайн работает
-- независимо от студенческого кода. Аэропорты вылета/прилёта одинаковы во всех
-- версиях одного route_no — безопасно брать из ODS без point-in-time логики.
-- ROW_NUMBER по validity DESC: выбираем актуальную версию расписания маршрута.
LEFT JOIN (
SELECT route_no, departure_airport, arrival_airport
FROM (
SELECT route_no, departure_airport, arrival_airport,
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
FROM ods.routes
) ranked
WHERE rn = 1
) AS ods_rte ON ods_rte.route_no = flt.route_no
LEFT JOIN dds.dim_routes AS rte
ON rte.route_bk = flt.route_no
AND flt.scheduled_departure::DATE >= rte.valid_from
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
LEFT JOIN dds.dim_calendar AS cal
ON cal.date_actual = flt.scheduled_departure::DATE
LEFT JOIN dds.dim_airports AS dep
ON dep.airport_bk = ods_rte.departure_airport -- через ods.routes (эталон)
LEFT JOIN dds.dim_airports AS arr
ON arr.airport_bk = ods_rte.arrival_airport -- через ods.routes (эталон)
LEFT JOIN dds.dim_airplanes AS ap
ON ap.airplane_bk = rte.airplane_code -- через dim_routes (point-in-time, как прежде)
LEFT JOIN dds.dim_tariffs AS tar
ON tar.fare_conditions = seg.fare_conditions
LEFT JOIN dds.dim_passengers AS pax
ON pax.passenger_id = tkt.passenger_id
LEFT JOIN ods.boarding_passes AS bp
ON bp.ticket_no = seg.ticket_no
AND bp.flight_id = seg.flight_id
)
INSERT INTO dds.fact_flight_sales (
calendar_sk,
departure_airport_sk,
arrival_airport_sk,
airplane_sk,
tariff_sk,
passenger_sk,
route_sk,
book_ref,
ticket_no,
flight_id,
book_date,
seat_no,
price,
is_boarded,
_load_id,
_load_ts
)
SELECT
s.calendar_sk,
s.departure_airport_sk,
s.arrival_airport_sk,
s.airplane_sk,
s.tariff_sk,
s.passenger_sk,
s.route_sk,
s.book_ref,
s.ticket_no,
s.flight_id,
s.book_date,
s.seat_no,
s.price,
s.is_boarded,
'{{ run_id }}',
now()
FROM fact_src AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.fact_flight_sales AS f
WHERE f.ticket_no = s.ticket_no
AND f.flight_id = s.flight_id
);
ANALYZE dds.fact_flight_sales;
+45
View File
@@ -0,0 +1,45 @@
-- DDL для витрины dm.airport_traffic (Пассажиропоток аэропортов).
--
-- Учебные цели:
-- 1. Обработка dual-role dimensions: один аэропорт выступает и как точка вылета,
-- и как точка прилета. Витрина собирает статистику в едином разрезе (date, airport).
-- 2. Денормализация: включение кода аэропорта (BK) и города для удобства анализа.
-- 3. Предупреждение о семантике данных: риск "двойного счета" выручки.
-- 4. Стратегия HWM по датам: пересчет только затронутых дней.
CREATE TABLE IF NOT EXISTS dm.airport_traffic (
-- Зерно: дата и суррогатный ключ аэропорта
traffic_date DATE NOT NULL,
airport_sk INTEGER NOT NULL,
-- Денормализованные атрибуты (из dim_airports)
airport_bk TEXT NOT NULL,
city TEXT NOT NULL,
-- Метрики вылета
departures_flights INTEGER NOT NULL DEFAULT 0,
departures_passengers INTEGER NOT NULL DEFAULT 0,
departures_revenue NUMERIC(15,2) NOT NULL DEFAULT 0,
-- Метрики прилета
arrivals_flights INTEGER NOT NULL DEFAULT 0,
arrivals_passengers INTEGER NOT NULL DEFAULT 0,
arrivals_revenue NUMERIC(15,2) NOT NULL DEFAULT 0,
-- Итоговые метрики
total_passengers INTEGER NOT NULL DEFAULT 0,
-- Служебные поля
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
_load_id TEXT NOT NULL,
_load_ts TIMESTAMP NOT NULL DEFAULT now()
)
WITH (appendonly=false) -- Используем Heap для инкрементального UPSERT
DISTRIBUTED BY (airport_sk);
COMMENT ON TABLE dm.airport_traffic IS 'Витрина: ежедневный пассажиропоток аэропортов (UNION ALL, Heap)';
-- Комментарии к колонкам с предупреждением (учебная ценность)
COMMENT ON COLUMN dm.airport_traffic.departures_revenue IS 'Выручка от билетов, где аэропорт был точкой вылета. ВНИМАНИЕ: суммирование с arrivals_revenue приведет к двойному счету!';
COMMENT ON COLUMN dm.airport_traffic.arrivals_revenue IS 'Выручка от билетов, где аэропорт был точкой прилета. ВНИМАНИЕ: суммирование с departures_revenue приведет к двойному счету!';
+49
View File
@@ -0,0 +1,49 @@
-- DQ проверки для витрины dm.airport_traffic.
--
-- Учебные цели:
-- 1. Проверка арифметических инвариантов (сумма частей должна быть равна целому).
-- 2. Валидация уникальности составного ключа (зерна).
DO $$
DECLARE
row_count INTEGER;
duplicate_count INTEGER;
invalid_total_count INTEGER;
negative_metrics_count INTEGER;
BEGIN
-- 1. Проверка на наполненность
SELECT COUNT(*) INTO row_count FROM dm.airport_traffic;
IF row_count = 0 THEN
RAISE EXCEPTION 'DQ Error: Таблица dm.airport_traffic пуста.';
END IF;
-- 2. Проверка на уникальность (зерно - traffic_date + airport_sk)
SELECT COUNT(*) INTO duplicate_count
FROM (
SELECT traffic_date, airport_sk FROM dm.airport_traffic
GROUP BY traffic_date, airport_sk HAVING COUNT(*) > 1
) q;
IF duplicate_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.airport_traffic обнаружены дубликаты по (date, airport_sk) (% шт).', duplicate_count;
END IF;
-- 3. Проверка инварианта total = departures + arrivals
SELECT COUNT(*) INTO invalid_total_count
FROM dm.airport_traffic
WHERE total_passengers != (departures_passengers + arrivals_passengers);
IF invalid_total_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.airport_traffic нарушен инвариант total_passengers = dep + arr (% строк).', invalid_total_count;
END IF;
-- 4. Проверка на отрицательные значения
SELECT COUNT(*) INTO negative_metrics_count
FROM dm.airport_traffic
WHERE departures_flights < 0 OR arrivals_flights < 0 OR departures_revenue < 0 OR arrivals_revenue < 0;
IF negative_metrics_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.airport_traffic обнаружены отрицательные метрики (% строк).', negative_metrics_count;
END IF;
RAISE NOTICE 'DQ Success: dm.airport_traffic успешно прошла все проверки (% строк).', row_count;
END $$;
+131
View File
@@ -0,0 +1,131 @@
-- Загрузка витрины dm.airport_traffic: инкрементальный UPSERT по датам.
--
-- Учебные цели:
-- 1. Обработка dual-role dimensions через UNION ALL:
-- Мы превращаем один билет (факт) в два "события": вылет и прилет.
-- Это позволяет собрать единую статистику аэропорта в одном проходе.
-- 2. Инкремент по датам (HWM): в этой витрине метрики ограничены сутками,
-- поэтому пересчитываем только те дни, где появились новые факты.
-- 3. Агрегация рейсов: используем COUNT(DISTINCT flight_id) для подсчета рейсов.
-- 4. Денормализация (SCD1): обновление атрибутов (город) при изменениях в измерении.
-- Шаг 1: Находим даты, затронутые новыми/измененными фактами.
CREATE TEMP TABLE tmp_traffic_affected_dates ON COMMIT DROP AS
SELECT DISTINCT cal.date_actual AS traffic_date
FROM dds.fact_flight_sales f
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
WHERE f._load_ts > (
SELECT COALESCE(MAX(_load_ts), '1900-01-01'::TIMESTAMP)
FROM dm.airport_traffic
);
-- Шаг 2: Unpivot (UNION ALL) и агрегация по затронутым датам.
CREATE TEMP TABLE tmp_airport_traffic_delta ON COMMIT DROP AS
WITH raw_events AS (
-- Роль 1: Аэропорт вылета
SELECT
cal.date_actual AS traffic_date,
f.departure_airport_sk AS airport_sk,
f.flight_id,
'departure' AS role,
(CASE WHEN f.is_boarded THEN 1 ELSE 0 END) AS is_passenger,
f.price AS revenue
FROM dds.fact_flight_sales f
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
WHERE cal.date_actual IN (SELECT traffic_date FROM tmp_traffic_affected_dates)
UNION ALL
-- Роль 2: Аэропорт прилета
SELECT
cal.date_actual AS traffic_date,
f.arrival_airport_sk AS airport_sk,
f.flight_id,
'arrival' AS role,
(CASE WHEN f.is_boarded THEN 1 ELSE 0 END) AS is_passenger,
f.price AS revenue
FROM dds.fact_flight_sales f
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
WHERE cal.date_actual IN (SELECT traffic_date FROM tmp_traffic_affected_dates)
)
SELECT
e.traffic_date,
e.airport_sk,
a.airport_bk, -- Берем из dim_airports.airport_bk
a.city,
-- Агрегация вылетов
COUNT(DISTINCT CASE WHEN e.role = 'departure' THEN e.flight_id END) AS departures_flights,
SUM(CASE WHEN e.role = 'departure' THEN e.is_passenger ELSE 0 END) AS departures_passengers,
SUM(CASE WHEN e.role = 'departure' THEN e.revenue ELSE 0 END) AS departures_revenue,
-- Агрегация прилетов
COUNT(DISTINCT CASE WHEN e.role = 'arrival' THEN e.flight_id END) AS arrivals_flights,
SUM(CASE WHEN e.role = 'arrival' THEN e.is_passenger ELSE 0 END) AS arrivals_passengers,
SUM(CASE WHEN e.role = 'arrival' THEN e.revenue ELSE 0 END) AS arrivals_revenue,
-- Итого: все пассажиры (сели + вышли в этом аэропорту)
SUM(e.is_passenger) AS total_passengers
FROM raw_events e
JOIN dds.dim_airports a ON e.airport_sk = a.airport_sk
GROUP BY e.traffic_date, e.airport_sk, a.airport_bk, a.city;
-- Шаг 3: UPDATE существующих записей (включая денормализованные атрибуты).
UPDATE dm.airport_traffic AS tgt
SET
airport_bk = src.airport_bk,
city = src.city,
departures_flights = src.departures_flights,
departures_passengers = src.departures_passengers,
departures_revenue = src.departures_revenue,
arrivals_flights = src.arrivals_flights,
arrivals_passengers = src.arrivals_passengers,
arrivals_revenue = src.arrivals_revenue,
total_passengers = src.total_passengers,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM tmp_airport_traffic_delta AS src
WHERE tgt.traffic_date = src.traffic_date
AND tgt.airport_sk = src.airport_sk
AND (
tgt.total_passengers IS DISTINCT FROM src.total_passengers
OR tgt.departures_flights IS DISTINCT FROM src.departures_flights
OR tgt.arrivals_flights IS DISTINCT FROM src.arrivals_flights
OR tgt.city IS DISTINCT FROM src.city
OR tgt.airport_bk IS DISTINCT FROM src.airport_bk
);
-- Шаг 4: INSERT новых записей.
INSERT INTO dm.airport_traffic (
traffic_date,
airport_sk,
airport_bk,
city,
departures_flights,
departures_passengers,
departures_revenue,
arrivals_flights,
arrivals_passengers,
arrivals_revenue,
total_passengers,
_load_id
)
SELECT
src.traffic_date,
src.airport_sk,
src.airport_bk,
src.city,
src.departures_flights,
src.departures_passengers,
src.departures_revenue,
src.arrivals_flights,
src.arrivals_passengers,
src.arrivals_revenue,
src.total_passengers,
'{{ run_id }}' AS _load_id
FROM tmp_airport_traffic_delta AS src
WHERE NOT EXISTS (
SELECT 1 FROM dm.airport_traffic AS tgt
WHERE tgt.traffic_date = src.traffic_date
AND tgt.airport_sk = src.airport_sk
);
ANALYZE dm.airport_traffic;
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more