Author SHA1 Message Date
ddmitry b6e950f52c docs: добавлены карта и руководство по структуре DWH (#5) 2026-09-20 20:23:59 +03:00
ddadmin ec9dd4c1a8 docs(map): ссылки на материалы перенесены в Gitea 2026-09-20 13:20:39 -04:00
ddadmin 6be0442838 docs(map): уточнены заголовки и добавлены образцы легенды 2026-09-20 13:11:44 -04:00
ddadmin b9bcfe7230 docs(map): добавлены карта и руководство по структуре DWH
Объяснены зерно таблиц, связи и путь бронирования до витрины.
Добавлена автономная карта с двумя видами связей и адресами объектов.
Состояние заглушек сверено с DDL и ТЗ; SQL и DAG не изменялись.

Проверки: сборка и ссылки, make lint, pytest (4 passed, 14 skipped),
Chromium file:// offline: выбор, связи, сброс, история и узкий экран.

Refs #2
2026-09-20 13:01:57 -04:00
ddadminandClaude Opus 5 77d72b08c6 docs(agents): настройка трекера, меток триажа и доменных доков для скиллов
- AGENTS.md: блок «Agent skills» со ссылками на docs/agents/
- docs/agents/issue-tracker.md: Gitea через tea CLI
- docs/agents/triage-labels.md: стандартные пять меток
- docs/agents/domain.md: один контекст, CONTEXT.md + docs/adr/
- CLAUDE.md: секция «Читабельность языка»

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:59:47 -04:00
ddadmin 98030f8b0d Merge branch 'docs/zoom-ladder': лесенка схем, метод чтения пайплайна и таксономия DQ 2026-07-20 14:58:56 +03:00
ddadminandClaude Fable 5 4a04a2caf6 docs: лесенка схем, метод чтения пайплайна и таксономия DQ
Одна диаграмма отвечает на один вопрос: db_schema.md перестроен в лесенку
из четырёх схем (слои, звезда, сборка факта, витрины), полная lineage-карта
перенесена в конец как справочная. Новые документы: reading_the_pipeline.md
(метод чтения FROM/JOIN load-скрипта, разбор fact_src) и dq_taxonomy.md
(три класса DQ-проверок на реальных скриптах, семантика NULL). Точки входа:
корневой README, docs/README.md, assignment/README.md, qa-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:56:42 +03:00
ddadminandClaude Opus 4.6 eb4cbbe730 docs(main): адаптирован qa-plan для ветки main (пометки заглушек)
- Зачем:
  - qa-plan утверждал «ВСЕ таблицы > 0», что неверно на main с 9 заглушками.
- Что:
  - добавлена секция в intro со списком пустых таблиц на main.
  - SQL-комментарии DDS/DM дополнены пометками заглушек.
  - ожидание разделено на solution (все > 0) и main (эталонные > 0).
- Проверка:
  - make test (4 passed, 14 skipped).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 11:35:48 +03:00
ddadminandClaude Opus 4.6 af27314878 docs(main): онбординг студента — гид, маркетинг, адаптация docs и docstrings
- Зачем:
  - на main docs и DAG-docstrings утверждали «все реализовано», хотя 18 заглушек;
    студент застревал после шага 6 без явного «что дальше».
- Что:
  - README: маркетинг-баннер, шаг 6 (только эталонные таблицы), шаг 7 → задания.
  - docs/assignment/README: полный гид студента (эталон → ТЗ → заглушки → validate).
  - analyst_spec: DAG-интеграция — «таски уже подключены, менять DAG не нужно».
  - docs ODS/DDS/DM: пометки заглушек, адаптация секций проверки результата.
  - 4 DAG docstrings: эталон vs задания (заглушки).
- Проверка:
  - make test (4 passed, 14 skipped), make lint (clean).
  - grep «все 5 витрин|все реализован» — ложных утверждений без оговорок нет.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 10:49:40 +03:00
ddadminandClaude Opus 4.6 5b98a5b203 docs(main): очистка docs, адаптация для студентов, починка ссылок
Шаг 5 плана main/solution split:
- Удалены внутренние документы с main: plans/, archive/, PRD,
  assignment_design, pxf_bookings, bookings_tz, benchmarks, TODO.md
- AGENTS.md: убраны упоминания plans/archive, agent-dag-testing
- Починены 19 битых markdown-ссылок во всех оставшихся файлах
- bookings_ods_design: airplanes/seats помечены как студенческие,
  обновлён DAG-граф (routes без зависимости от airplanes)
- bookings_dds_design: обновлено описание DQ факта (student SK)
- bookings_to_gp_dds: обновлена DQ-семантика для main
- qa-plan: уточнено — ods.airplanes/seats пусты by design на main

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:14:22 +03:00
ddadminandClaude Opus 4.6 5ca4e6b2ca feat(main): заглушки студенческих файлов, ослабление DQ, адаптация тестов
- fact_flight_sales_dq: student SK (passenger/route/airplane) → NOTICE,
  airport_sk → порог 1% (эталон через ods.routes)
- routes_dq: закомментирован RI airplane_code → ods.airplanes
- ODS DAG: убрана зависимость dq_ods_airplanes → load_ods_routes
- 18 студенческих файлов заменены заглушками (ODS/DDS/DM load+dq)
- test_ods_sql_contract: SNAPSHOT_ENTITIES без airplanes/seats
- test_dags_smoke: убран assert dq_ods_airplanes → dq_ods_routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:07:14 +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
179 changed files with 24544 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/
+69 -33
View File
@@ -1,42 +1,64 @@
# 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/` (справочники), `assignment/` (задание для студента).
- `docs/design/naming_conventions.md` — единый источник нейминга служебных и SCD-полей. *Правило ИИ: всегда сверяться при генерации DDL/SQL.*
- Полные дизайн-документы (PRD, assignment_design, планы, архив) — в ветке `solution`.
- `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` (пошаговый чек‑лист для студентов).
@@ -47,11 +69,25 @@
- При изменении схемы/поведения — обновляйте `README.md` и `sql/ddl_gp.sql`.
## Безопасность и конфигурация
- Все настройки — через `.env`; креды в коде не хардкодим. Частые переменные: `GP_*`, `PG_*`, `AIRFLOW_*`, `CSV_*`.
- `make down` удаляет тома — предупреждайте студентов, что данные пропадут.
- Все настройки — через `.env`; креды в коде не хардкодим. Частые переменные: `GP_*`, `PG_*`, `AIRFLOW_*`.
- `make clean` удаляет тома — предупреждайте студентов, что данные пропадут.
## Для агента (особенности аудитории)
- Пишите простыми словами. Добавляйте короткие комментарии к нетривиальной логике.
- Избегайте больших рефакторингов и сложных паттернов — студенты только начинают.
- Ошибки и логи — дружелюбные и понятные (лучше с подсказкой «что сделать дальше»).
- Перед релевантными правками валидируйте локально: `make up && make airflow-init`, затем откройте DAG в UI и/или прогоните `make test`.
- Перед релевантными правками валидируйте локально: `make up`, затем откройте DAG в UI и/или прогоните `make test`.
## Agent skills
### Issue tracker
Issues живут в Gitea (`git.dementev.space/ddmitry/airflow-greenplum`), работа через `tea` CLI. См. `docs/agents/issue-tracker.md`.
### Triage labels
Стандартный набор: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. См. `docs/agents/triage-labels.md`.
### Domain docs
Один контекст: `CONTEXT.md` и `docs/adr/` в корне репо. См. `docs/agents/domain.md`.
+7
View File
@@ -0,0 +1,7 @@
@AGENTS.md
# Читабельность языка
Пиши легко для чтения. Сокращай количество мыслей, а не количество слов, которыми выражена каждая мысль.
Please remove all mannered prose.
+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
+132 -236
View File
@@ -1,287 +1,183 @@
# 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 ods.bookings;
SELECT COUNT(*) FROM dds.dim_airports;
SELECT COUNT(*) FROM dds.fact_flight_sales;
SELECT COUNT(*) FROM dm.sales_report;
-- Остальные DDS/DM-таблицы пока пусты — вы реализуете их в задании.
```
#### Быстрый старт с uv
Подробнее про логику DAG и проверки — `docs/bookings_to_gp_stage.md`.
7) Разберитесь в данных и приступайте к заданию:
Стенд работает, данные загружены. Откройте
[руководство о структуре DWH](docs/design/db_schema.md): на примере поездки
оно проведет от билетов и сегментов к факту и витрине. Рядом держите
[интерактивную карту](docs/design/architecture-map.html) — локальный файл
`docs/design/architecture-map.html` нужно открыть в браузере; в Gitea HTML
показывается как исходный текст. Затем переходите к
[учебным заданиям](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)
- [Структура DWH: от бронирования до витрины](docs/design/db_schema.md)
- [Интерактивная карта таблиц и связей](docs/design/architecture-map.html)
- [Как читать пайплайн по скрипту загрузки](docs/design/reading_the_pipeline.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.
+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.
DDL создаёт все 5 витрин. Эталон: sales_report. Остальные — задания.
"""
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
)
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
"""
Учебный DAG: загрузка из ODS в DDS (Greenplum) по домену bookings.
Ключевая идея:
- DDS читает текущее состояние ODS;
- для каждой сущности выполняем пару задач load -> dq;
- для dim_routes применяем SCD2, для остальных измерений — SCD1 UPSERT;
- факт грузим инкрементальным UPSERT по зерну (ticket_no, flight_id).
Эталон: dim_airports, dim_tariffs, dim_calendar, fact_flight_sales. dim_routes, dim_passengers, dim_airplanes — задания (заглушки).
"""
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).
Эталон: sales_report. Остальные 4 витрины — задания (заглушки SELECT 1;).
"""
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
+277
View File
@@ -0,0 +1,277 @@
from __future__ import annotations
"""
Учебный DAG: загрузка из STG в ODS (Greenplum) по домену bookings.
Ключевая идея:
- весь запуск ODS работает с одним stg_batch_id;
- для каждой сущности выполняем пару задач load -> dq;
- загрузка реализована как SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
Эталон: airports, routes и 5 инкрементальных сущностей. airplanes, seats — задания (заглушки).
"""
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
# На main routes не зависит от airplanes (ods.airplanes — студенческая заглушка).
# На ветке solution: [dq_ods_airports, dq_ods_airplanes] >> load_ods_routes
dq_ods_airports >> 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:
+40
View File
@@ -0,0 +1,40 @@
# Документация
Этот каталог содержит дополнительные материалы к учебному стенду.
## Быстрый путь (для менти)
- [Быстрый старт и команды](../README.md)
- [Структура DWH: от бронирования до витрины](design/db_schema.md)
- [Интерактивная карта таблиц и связей](design/architecture-map.html)
- [Учебные задания](assignment/README.md)
- [Как читать пайплайн по скрипту загрузки](design/reading_the_pipeline.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)
Карту открывайте из локальной копии: `docs/design/architecture-map.html` в браузере.
Сервер не нужен. Просмотр исходного HTML в Gitea не показывает карту.
[Руководство](design/db_schema.md#как-открыть-карту) объясняет открытие и адресные ссылки.
## Дизайн (`design/`)
- [Единые конвенции нейминга DWH (служебные поля и SCD)](design/naming_conventions.md)
- [Структура DWH и маршрут по карте](design/db_schema.md)
- [Как читать пайплайн по скрипту загрузки](design/reading_the_pipeline.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)
> Полные дизайн-документы (PRD, assignment_design, архитектурные решения) — в ветке `solution`.
## Справочники (`reference/`)
- [Как устроен Docker-стенд (образы, Connections, переменные окружения)](stack.md)
- [Известные проблемы bookings-db](reference/bookings_db_issues.md)
- [QA-план отладки пайплайна](reference/qa-plan.md)
- [Классы DQ-проверок](reference/dq_taxonomy.md)
- [Порядок запуска DAG-ов](dag_execution_order.md)
+51
View File
@@ -0,0 +1,51 @@
# Доменная документация
Как скиллам читать доменную документацию этого репо перед работой с кодом.
## Что читать перед началом
- **`CONTEXT.md`** в корне репо, или
- **`CONTEXT-MAP.md`** в корне, если он есть: указывает на отдельный `CONTEXT.md` для каждого контекста. Читайте те, что относятся к теме.
- **`docs/adr/`**: читайте ADR, которые касаются области, с которой собираетесь работать. В мультиконтекстных репо также проверяйте `src/<context>/docs/adr/`.
Если каких-то из этих файлов нет — **работайте молча**. Не отмечайте их отсутствие и не предлагайте создать заранее. Скилл `/domain-modeling` (через `/grill-with-docs` и `/improve-codebase-architecture`) создаёт их по мере того, как термины и решения реально фиксируются.
## Структура файлов
Один контекст (этот репо и большинство других):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Несколько контекстов (признак — `CONTEXT-MAP.md` в корне):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← решения уровня системы
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← решения этого контекста
└── billing/
├── CONTEXT.md
└── docs/adr/
```
## Используйте словарь глоссария
Когда в выводе называете доменное понятие (в заголовке issue, предложении по рефакторингу, гипотезе, имени теста), берите термин из `CONTEXT.md`. Не съезжайте на синонимы, которых глоссарий избегает.
Если нужного понятия в глоссарии нет — это сигнал: либо вы придумываете слова, которых проект не использует (пересмотрите), либо в глоссарии пробел (отметьте для `/domain-modeling`).
## Отмечайте конфликты с ADR
Если ваш вывод противоречит существующему ADR, скажите об этом явно, а не переопределяйте молча:
> _Противоречит ADR-0007 (event-sourced orders), но стоит пересмотреть, потому что…_
+51
View File
@@ -0,0 +1,51 @@
# Трекер задач: Gitea
Issues и спеки этого репо живут в Gitea:
`https://git.dementev.space/ddmitry/airflow-greenplum`.
Все операции — через CLI [`tea`](https://gitea.com/gitea/tea).
`gh` и `glab` с этим хостом не работают.
`tea` сам определяет репо и логин по `git remote -v`, если запущен внутри клона
(логин `dementev`, пользователь `ddmitry`). Для машиночитаемого вывода
добавляйте `-o json` к любой команде `list`.
## Команды
- **Создать issue**: `tea issues create --title "..." --description "..."`. Для многострочного описания используйте heredoc или `"$(cat file.md)"`. `--labels a,b` вешает метки сразу при создании.
- **Прочитать issue**: `tea issues <номер> --comments`.
- **Список issues**: `tea issues list --state open -o json --fields index,title,state,labels,assignees,body`. Фильтры: `--labels`, `--state all|open|closed`, `--keyword`, `--assignee`.
- **Прокомментировать**: `tea comments add <номер> "..."` (короткая форма: `tea comment <номер> "..."`).
- **Добавить / снять метки**: `tea issues edit <номер> --add-labels "a,b"` / `--remove-labels "a,b"`. Метка должна существовать в репо: `tea labels create --name "..." --color "..." --description "..."`.
- **Закрыть**: `tea issues close <номер>`. Комментарий при закрытии команда не принимает — сначала `tea comments add`, потом `close`.
- **Pull requests**: `tea pulls create`, `tea pulls <номер>`, `tea pulls list`, `tea pulls merge` — тот же набор, что у `tea issues`. Комментарии к PR — через тот же `tea comments add <номер>`.
## PR как источник запросов
**PR как источник запросов: нет.** _(Поставьте `да`, если внешние PR нужно рассматривать как feature request; `/triage` читает этот флаг.)_
Если стоит `да`, PR проходят те же метки и состояния, что и issues, через `tea pulls`:
- **Прочитать PR**: `tea pulls <номер> --comments`; diff — `tea pulls checkout <номер>` и `git diff main...`.
- **Список внешних PR для триажа**: `tea pulls list --state open -o json --fields index,title,author,labels,body`, дальше оставить только PR от авторов, которые не являются участниками репо.
- **Комментарий / метки / закрытие**: `tea comments add`, `tea issues edit --add-labels`/`--remove-labels` (работает и для номеров PR), `tea pulls close`.
В Gitea issues и PR нумеруются в одном пространстве: `#42` может быть и тем, и другим. `tea issues 42` показывает поле `kind`; ещё можно смотреть `tea issues list --kind all`.
## Когда скилл говорит «опубликовать в трекер»
Создать issue в Gitea: `tea issues create`.
## Когда скилл говорит «взять тикет»
Выполнить `tea issues <номер> --comments`.
## Операции wayfinding
Используется `/wayfinder`. **Карта** — одна issue, **дочерние** issues — тикеты.
- **Карта**: одна issue с меткой `wayfinder:map`, в теле — Notes / Decisions-so-far / Fog. `tea issues create --labels wayfinder:map`.
- **Дочерний тикет**: issue со строкой `Part of #<карта>` в начале описания и меткой `wayfinder:<тип>` (`research`/`prototype`/`grilling`/`task`). После взятия в работу тикет назначается на исполнителя.
- **Блокировки**: в Gitea есть встроенные зависимости между issues, но `tea` их не поддерживает. Используйте строку `Blocked by: #<n>, #<n>` в начале описания дочернего тикета. Тикет разблокирован, когда все блокирующие закрыты.
- **Поиск фронтира**: `tea issues list --state open --labels wayfinder:task -o json` (повторить для каждого типа), оставить только детей этой карты, отбросить те, у кого есть открытый блокер в `Blocked by` или назначенный исполнитель; первый по порядку в карте.
- **Взять в работу**: `tea issues edit <n> --add-assignees ddmitry` — первая запись в сессии.
- **Закрыть**: `tea comments add <n> "<ответ>"`, затем `tea issues close <n>`, затем дописать указатель на контекст (суть + ссылка) в Decisions-so-far карты.
+18
View File
@@ -0,0 +1,18 @@
# Метки триажа
Скиллы оперируют пятью ролями триажа. Эта таблица сопоставляет роли с реальными метками в трекере этого репо.
| Роль в mattpocock/skills | Метка в нашем трекере | Значение |
| ------------------------ | --------------------- | ---------------------------------------------- |
| `needs-triage` | `needs-triage` | Мейнтейнеру нужно оценить issue |
| `needs-info` | `needs-info` | Ждём уточнений от автора |
| `ready-for-agent` | `ready-for-agent` | Полностью описано, можно отдавать AFK-агенту |
| `ready-for-human` | `ready-for-human` | Требует реализации человеком |
| `wontfix` | `wontfix` | Делать не будем |
Когда скилл упоминает роль (например, «повесить метку готовности для агента»), берите строку из правой колонки.
Правую колонку можно менять под свой словарь.
В Gitea-репо меток пока нет. При первом `/triage` создайте их:
`tea labels create --name needs-triage --color "#e4e669" --description "..."` и так далее для каждой строки таблицы.
+85
View File
@@ -0,0 +1,85 @@
# Учебные задания
## О чём задание
В стенде уже работает **эталонный вертикальный срез** — полная цепочка
`STG → ODS → DDS → DM` для витрины `dm.sales_report`. Ваша задача —
реализовать недостающие загрузки по аналогии с эталоном.
На месте ваших заданий сейчас стоят **заглушки** (`SELECT 1;`). Вы замените
их на реальную SQL-логику, и после этого весь DWH будет заполнен.
## С чего начать чтение
Сначала посмотрите [руководство о структуре DWH и карту](../design/db_schema.md).
Пока пишете свой слой, держите рядом [метод чтения скрипта загрузки](../design/reading_the_pipeline.md).
Для `*_dq.sql` сверяйтесь с [классами DQ-проверок](../reference/dq_taxonomy.md).
## Перед началом
Убедитесь, что стенд запущен и данные загружены —
пройдите шаги 1–6 из [README (Быстрый старт)](../../README.md#быстрый-старт-основной-сценарий-bookings--stg--ods--dds--dm).
После этого в Greenplum будут данные в эталонных таблицах (`stg.*`, `ods.bookings`,
`dds.dim_airports`, `dds.fact_flight_sales`, `dm.sales_report` и др.).
## Шаг 1. Изучите эталон
Прежде чем писать код, разберите, как устроены эталонные загрузки.
По одному файлу на каждый ключевой паттерн:
| Паттерн | Файл для изучения | Что посмотреть |
|---------|-------------------|----------------|
| ODS: TRUNCATE + INSERT (snapshot-справочник) | `sql/ods/airports_load.sql` | Фильтрация по `stg_batch_id`, дедупликация через `ROW_NUMBER()` |
| DDS: SCD1 UPSERT (измерение) | `sql/dds/dim_airports_load.sql` | TEMP TABLE → UPDATE (`IS DISTINCT FROM`) → INSERT |
| DM: витрина с UPSERT | `sql/dm/sales_report_load.sql` | HWM-инкремент, агрегация фактов по измерениям |
Описания каждого DAG — в `docs/`:
[STG](../bookings_to_gp_stage.md) ·
[ODS](../bookings_to_gp_ods.md) ·
[DDS](../bookings_to_gp_dds.md) ·
[DM](../bookings_to_gp_dm.md)
## Шаг 2. Откройте ТЗ
Техническое задание от аналитика: **[analyst_spec.md](analyst_spec.md)**.
Там описаны все таблицы, которые нужно реализовать, с маппингами, бизнес-правилами
и подсказками. Рекомендуемый порядок (от простого к сложному):
1. ODS: `airplanes`, `seats`
2. DDS: `dim_airplanes`, `dim_passengers` (SCD1)
3. DDS: `dim_routes` (SCD2 — ключевой вызов курсовой)
4. DM: `airport_traffic`, `route_performance`, `monthly_overview`, `passenger_loyalty`
## Шаг 3. Реализуйте задания
- **Файлы-заглушки уже на месте** — например, `sql/ods/airplanes_load.sql`
содержит `SELECT 1;`. Ваша задача: заменить `SELECT 1;` на реальную SQL-логику.
- **DDL уже создан** — таблицы существуют, создавать их не нужно.
- **Таски в DAG-ах уже подключены** — `PostgresOperator` ссылается на ваши SQL-файлы.
Менять Python-код DAG-ов не нужно.
- **Нейминг полей:** сверяйтесь с `docs/design/naming_conventions.md`.
## Шаг 4. Проверьте себя валидационным DAG-ом
DAG **`bookings_validate`** — автоматическая проверка вашей реализации.
- Три группы проверок: `validate_ods`, `validate_dds`, `validate_dm` — запускаются
параллельно. Можно проверять ODS, пока DDS ещё не готов.
- Включает активный SCD2-тест для `dim_routes`: загружает изменённые данные
и проверяет, что версионирование работает корректно.
- Сообщения об ошибках подскажут, что именно не так и что делать дальше.
**Цель: все проверки зелёные.**
## Если застряли
- Изучите эталонные SQL-скрипты — в них те же паттерны, что нужны для заданий.
- Загляните в `docs/design/naming_conventions.md` — единый источник нейминга полей.
- Сверьтесь с **веткой `solution`** — там полная рабочая реализация.
## Для менторов
Дизайн заданий и педагогическая логика — в ветке `solution`
(`docs/design/assignment_design.md`).
+653
View File
@@ -0,0 +1,653 @@
# Техническое задание: расширение 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-интеграция:** таски для всех заданий уже подключены в DAG-файлах
соответствующего слоя (`PostgresOperator` + путь к SQL-файлу).
Вам нужно только заменить содержимое 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
```
+196
View File
@@ -0,0 +1,196 @@
# 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)`.
> На ветке `main` измерения `dim_routes`, `dim_passengers`, `dim_airplanes` —
> заглушки. Эталон: `dim_calendar`, `dim_airports`, `dim_tariffs`, `fact_flight_sales`.
- Для каждой таблицы выполняет пару задач `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` | Самолёты (код, модель, кол-во мест) ⚠️ заглушка на main |
| 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, имя, контакты) ⚠️ заглушка на main |
Паттерн загрузки — SCD1 UPSERT: TEMP TABLE → UPDATE (IS DISTINCT FROM) → INSERT.
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Описание ниже — спецификация того, что нужно реализовать. Образец SCD2 — в ветке `solution`.
### 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`. На ветке `solution` все измерения заполнены,
и NULL SK не возникают в штатном режиме. На ветке `main` студенческие измерения
(`dim_passengers`, `dim_routes`, `dim_airplanes`) — заглушки, поэтому соответствующие
SK будут NULL до реализации студентом.
DQ-проверки факта на main:
- `tariff_sk` — **запрещён** NULL (EXCEPTION);
- `departure_airport_sk`, `arrival_airport_sk` (через `ods.routes`, эталон) — порог **1%** NULL (EXCEPTION);
- `passenger_sk`, `route_sk`, `airplane_sk` (студенческие) — только **NOTICE** (100% NULL допустимо);
- `calendar_sk` — порог **1%** NULL.
> После реализации всех измерений: `TRUNCATE dds.fact_flight_sales` → перезагрузка →
> все SK заполнены. Полную версию DQ см. в ветке `solution`.
**Два пути 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_airports;
SELECT COUNT(*) FROM dds.dim_tariffs;
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;
-- dim_routes, dim_passengers, dim_airplanes — заглушки на main.
-- Проверки ниже станут осмысленны после реализации задания.
SELECT COUNT(*) FROM dds.dim_routes;
-- Проверка SCD2 (после реализации dim_routes):
-- текущие версии маршрутов (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`, `dim_calendar` и `dim_airports` непусты.
`dim_routes` — после реализации задания: `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 завершился успешно без пропуска задач.
+181
View File
@@ -0,0 +1,181 @@
# DAG `bookings_to_gp_dm`: `dds` → `dm` в Greenplum
Этот DAG — учебный пример загрузки слоя **DM** (Data Mart / витрины) из текущего состояния **DDS**.
Эталон: витрина `sales_report`. Остальные 4 витрины — задания (заглушки `SELECT 1;` на ветке `main`).
Все 5 витрин загружаются **параллельно** и демонстрируют разные стратегии загрузки —
это ключевая учебная ценность данного DAG.
## Что делает DAG
Загружает 5 витрин параллельно, для каждой — пара `load → dq`:
| Витрина | Зерно (grain) | Паттерн загрузки | Статус на main |
|---------|---------------|------------------|----------------|
| `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)`);
- **Денормализация**: города и коды аэропортов тянутся в витрину из измерений.
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
### 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`.
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
### 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 аномалиях в измерениях).
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
### 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) в одном проходе.
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
### 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;
```
Ожидаемо: `dm.sales_report` непуста. Остальные — после реализации заданий.
## Типичные ошибки
- `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 задачу с ошибкой и исправьте.
+180
View File
@@ -0,0 +1,180 @@
# 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` (на ветке `main` загрузка `airplanes` и `seats` —
заглушки; реализуйте их по ТЗ в `docs/assignment/analyst_spec.md`).
- Для каждой таблицы выполняет пару задач `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` ⚠️ заглушка на main |
| 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` ⚠️ заглушка на main |
| 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 строк) полная перезагрузка быстрее и чище.
> На ветке `main` загрузка `airplanes` и `seats` — заглушки.
> Паттерн TRUNCATE + INSERT описан выше; используйте `airports_load.sql` как образец.
**Транзакционные таблицы** (`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 задач.
+155
View File
@@ -0,0 +1,155 @@
# 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.
Для технических деталей PXF см. ветку `solution` (`docs/reference/pxf_bookings.md`).
## Рекомендации по качеству решения
Ревью решения и список улучшений — в ветке `solution` (`docs/archive/`).
+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). В учебных целях мы оставили их ручными, чтобы вы могли проинспектировать каждый слой после его загрузки.
File diff suppressed because it is too large. Load diff
+69
View File
@@ -0,0 +1,69 @@
# Исходники карты DWH
Готовый файл - [../architecture-map.html](../architecture-map.html).
Откройте его локально в браузере. Для самой карты сервер и интернет не требуются.
Ссылки на SQL, DAG и Markdown открывают Gitea, поэтому для переходов нужен интернет.
Учебный маршрут находится в [руководстве о структуре](../db_schema.md).
## Редактирование и сборка
- `map.json` - единственный каталог объектов, полей, связей и текстов карточек.
- `template.html` - интерфейс. SVG, карточки, постоянная раскладка и выделение
адаптированы из `clickstream-data-platform/docs/handbook/architecture-map/template.html`.
- `render.py` - сборка автономного HTML и статические проверки. Нужны uv и Python
3.11 или новее; пакеты Python и JavaScript не требуются. JSON заменяет YAML
образца, чтобы сборка обходилась стандартной библиотекой.
Из корня репозитория:
```bash
uv run docs/design/architecture-map/render.py
uv run docs/design/architecture-map/render.py --check
```
Правьте исходники, затем пересобирайте HTML. `--check` сверяет:
имена объектов с DDL/PXF, важные поля и поля связей, статусы загрузок и DQ,
входы готовых SQL со стрелками потока, файлы и разделы по ссылкам,
уникальность идентификаторов и позиций, свежесть HTML.
Проверка входов учитывает именованные таблицы в FROM/JOIN;
смысл соединений нужно сверять при изменении SQL вручную.
`repository_url` в `map.json` задает адрес Gitea с окончанием `/src/`.
В нем нет имени ветки или коммита: Gitea открывает файл из текущей ветки
по умолчанию. Пути в `links` остаются относительными к готовому HTML,
чтобы сборка проверяла файлы и разделы в локальном репозитории;
интерфейс преобразует их в адреса Gitea. Ссылки вида `#node=…` остаются
в локальной карте. Ссылка на руководство в шапке собирается из того же адреса.
Внешние `stg.*_ext` представлены в карточках STG, поскольку это входы чтения,
а не еще одна копия данных. Поля источника сверяются с контрактом PXF.
Для заглушек используются DDL и `docs/assignment/analyst_spec.md`.
Новые предположения о поведении загрузок в карточки не добавляйте.
## Адреса объектов
`../architecture-map.html#node=ods.airplanes` выбирает таблицу.
Дополнение `&view=keys` включает связи по ключам; без него показан поток данных.
Идентификатор равен полному имени таблицы и не зависит от позиции в карте.
Неизвестный адрес возвращает обзор с сообщением. Назад/вперед в браузере
восстанавливают предыдущий выбор и вид связей.
## Проверка в браузере
Статические проверки не заменяют этот проход:
1. Откройте готовый HTML с диска. Убедитесь, что видны пять слоев,
имена таблиц и пометки заданий; ошибок JavaScript нет.
2. Выберите `bookings.tickets`, затем `ods.segments` и `dds.fact_flight_sales`.
Проверьте источники, потребителей и карточки связей. Снимите выделение
повторным выбором, кнопкой "Общий вид" и клавишей Esc.
3. Для выбранных билета, факта и `dds.dim_airplanes` переключите оба вида:
выбор, позиции и прокрутка карты должны сохраниться.
4. У факта откройте обе роли `dim_airports`, путь через `ods.routes`
и выбор версии `dim_routes`. Проверьте отдельные карточки связей.
5. Откройте адрес объекта, обновите страницу, проверьте назад/вперед.
Откройте DDL, загрузку, DQ, DAG и пункт задания из карточки в Gitea.
Проверьте форматирование Markdown, подсветку кода и переход к разделу задания.
Ссылка на таблицу должна открывать локальную карту с тем же выбором.
6. Проверьте выбор клавишами Enter/пробел и вид на узком экране.
На узком экране карта прокручивается, карточка находится под ней.
File diff suppressed because it is too large. Load diff
+173
View File
@@ -0,0 +1,173 @@
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""Проверить данные карты и собрать автономный HTML без внешних зависимостей."""
from __future__ import annotations
import argparse
import json
import re
from html import escape
from pathlib import Path
from urllib.parse import unquote, urljoin, urlsplit
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[2]
OUTPUT = HERE.parent / "architecture-map.html"
MARKER = "/*__MAP_DATA__*/"
NOTICE = (
"<!-- Собрано из architecture-map/map.json и template.html. "
"Команда: uv run docs/design/architecture-map/render.py -->\n"
)
def headings(text: str) -> set[str]:
"""Якоря Markdown для ссылок на существующие разделы документации."""
result = set(re.findall(r'<a\s+id="([^"]+)"', text))
for title in re.findall(r"^#{1,6}\s+(.+)$", text, re.M):
slug = re.sub(r"[^\w\- ]", "", title.lower()).replace(" ", "-")
result.add(slug)
return result
def validate(data: dict) -> None:
"""Сверить объекты, поля, статусы, входы готовых SQL и локальные ссылки."""
repository = urlsplit(data["repository_url"])
if (
repository.scheme != "https"
or not repository.netloc
or not repository.path.endswith("/src/")
or repository.query
or repository.fragment
):
raise ValueError("Укажите HTTPS-адрес репозитория Gitea с окончанием /src/")
nodes = {node["id"]: node for node in data["nodes"]}
if len(nodes) != len(data["nodes"]):
raise ValueError("Повторяется идентификатор объекта")
edge_ids = [e["id"] for e in data["edges"]]
if len(set(edge_ids)) != len(edge_ids):
raise ValueError("Повторяется идентификатор связи")
columns, externals, tables = {}, {}, set()
for path in sorted((ROOT / "sql").glob("*/*_ddl.sql")):
text = re.sub(r"--[^\n]*", "", path.read_text(encoding="utf-8"))
pattern = r"CREATE\s+(EXTERNAL\s+)?TABLE\s+(?:IF NOT EXISTS\s+)?(\w+\.\w+)\s*\((.*?)\n\)"
for external, table, body in re.findall(pattern, text, re.S | re.I):
columns[table] = set(re.findall(r"^\s*(\w+)\s+\w+", body, re.M))
if external:
source = re.search(r"pxf://([^?]+)", text)[1]
columns[source] = columns[table]
externals[table] = source
else:
tables.add(table)
expected = tables | set(externals.values())
if set(nodes) != expected:
raise ValueError(f"Карта и DDL расходятся: {set(nodes) ^ expected}")
parts = {part for n in nodes.values() for part in n["parts"]}
if parts != set(externals):
raise ValueError("В карточках STG должны быть представлены все внешние таблицы")
layers = {layer["id"] for layer in data["layers"]}
positions = set()
for node in nodes.values():
if node["layer"] not in layers:
raise ValueError(f"Неизвестный слой: {node['id']}")
position = (node["layer"], node["order"])
if position in positions:
raise ValueError(f"Объекты перекрываются: {position}")
positions.add(position)
for field in ("what", "grain", "keys", "fields", "fill", "links"):
if not node[field]:
raise ValueError(f"Пустое поле {field}: {node['id']}")
for field in node["fields"]:
if field["name"] not in columns[node["id"]]:
raise ValueError(f"Нет поля {node['id']}.{field['name']} в DDL/PXF")
if node["layer"] == "source":
continue
layer, name = node["id"].split(".")
for role in ("load", "dq"):
path = ROOT / f"sql/{layer}/{name}_{role}.sql"
sql = re.sub(r"--[^\n]*", "", path.read_text(encoding="utf-8")).strip()
stub = sql.upper() == "SELECT 1;"
if stub != (node["status"] == "student"):
raise ValueError(
f"Статус карты не совпадает с {path.relative_to(ROOT)}"
)
if role != "load" or stub:
continue
sources = set(re.findall(r"\b(?:FROM|JOIN)\s+(\w+\.\w+)", sql, re.I))
sources = {externals.get(s, s) for s in sources if s != node["id"]}
sources &= expected
drawn = {
e["from"]
for e in data["edges"]
if e["mode"] == "flow" and e["to"] == node["id"]
}
if sources != drawn:
raise ValueError(
f"Входы SQL и карты расходятся у {node['id']}: {sources ^ drawn}"
)
for edge in data["edges"]:
if edge["from"] not in nodes or edge["to"] not in nodes:
raise ValueError(f"Неизвестный конец связи {edge['id']}")
if edge["mode"] not in ("flow", "keys") or not edge["join"] or not edge["refs"]:
raise ValueError(f"Неполное описание связи {edge['id']}")
for ref in edge["refs"]:
if (
ref["node"] not in nodes
or not set(ref["columns"]) <= columns[ref["node"]]
):
raise ValueError(f"Неизвестные поля связи {edge['id']}: {ref}")
count = 0
for item in [*nodes.values(), *data["edges"]]:
for link in item["links"]:
url = urlsplit(link["url"])
if url.scheme or url.netloc:
raise ValueError(f"Ссылка должна вести в локальный репозиторий: {link}")
path = (OUTPUT.parent / unquote(url.path)).resolve()
if not path.is_relative_to(ROOT) or not path.is_file():
raise ValueError(f"Файл по ссылке не найден: {link['url']}")
if url.fragment and unquote(url.fragment) not in headings(
path.read_text(encoding="utf-8")
):
raise ValueError(f"Раздел по ссылке не найден: {link['url']}")
count += 1
print(
f"Проверены {len(nodes)} объектов, {len(edge_ids)} связей, {count} ссылок; поля, статусы и входы SQL совпадают"
)
def main() -> int:
"""Собрать HTML либо проверить его свежесть вместе с исходными данными."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="проверить без записи")
args = parser.parse_args()
try:
data = json.loads((HERE / "map.json").read_text(encoding="utf-8"))
validate(data)
template = (HERE / "template.html").read_text(encoding="utf-8")
if template.count(MARKER) != 1:
raise ValueError("В шаблоне должен быть ровно один маркер данных")
payload = json.dumps(data, ensure_ascii=False, indent=2).replace("<", "\\u003c")
guide_url = urljoin(data["repository_url"], "docs/design/db_schema.md")
result = NOTICE + template.replace(MARKER, payload).replace(
"__GUIDE_URL__", escape(guide_url, quote=True)
)
if args.check:
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != result:
raise ValueError(
"HTML устарел. Выполните uv run docs/design/architecture-map/render.py"
)
print("HTML соответствует исходникам")
else:
OUTPUT.write_text(result, encoding="utf-8")
print(f"Собрана карта: {OUTPUT.relative_to(ROOT)}")
return 0
except (ValueError, KeyError, OSError) as exc:
print(f"Не удалось собрать карту: {exc}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+481
View File
@@ -0,0 +1,481 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Карта DWH авиаперевозок</title>
<style>
:root {
color-scheme: light;
--paper: #f7f7f5;
--panel: #ffffff;
--ink: #272a2d;
--muted: #6b7177;
--line: #8c9298;
--soft: #e6e8e9;
--accent: #b54a2a;
--accent-soft: #f5ded7;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
header { padding: 22px 26px 12px; }
h1 { margin: 0 0 5px; font-size: 25px; }
.lead { margin: 0; color: var(--muted); }
.controls {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 8px 26px 16px;
}
.switcher { display: flex; flex-wrap: wrap; gap: 6px; }
button {
border: 1px solid #aeb3b7;
border-radius: 18px;
background: var(--panel);
color: var(--ink);
padding: 6px 13px;
font: inherit;
cursor: pointer;
}
button[aria-pressed="true"] {
border-color: var(--accent);
background: var(--accent-soft);
color: #71301e;
}
button:focus-visible, [tabindex="0"]:focus-visible {
outline: 3px solid #d79078;
outline-offset: 2px;
}
.legend { display: flex; flex-wrap: wrap; gap: 8px 20px; color: var(--muted); }
.legend-item { display: inline-flex; align-items: center; gap: 7px; }
.legend-icon { display: block; width: 50px; height: 20px; flex: none; }
main {
display: grid;
grid-template-columns: minmax(0, 1fr) 370px;
gap: 16px;
padding: 0 18px 24px;
align-items: start;
}
.map-scroll {
overflow-x: auto;
border: 1px solid #d7d9da;
border-radius: 10px;
background: var(--panel);
}
#map { display: block; width: 100%; min-width: 1040px; height: auto; }
.layer-title { font-size: 15px; font-weight: 700; text-anchor: middle; }
.layer-caption { font-size: 11px; fill: var(--muted); text-anchor: middle; }
.zone-caption { font-size: 12px; fill: var(--muted); }
.column-guide { stroke: #eef0f1; stroke-width: 1; }
.edge-line {
fill: none;
stroke: var(--line);
stroke-width: 1.8;
marker-end: url(#arrowhead);
transition: opacity .15s, stroke .15s, stroke-width .15s;
}
.edge-line.dashed { stroke-dasharray: 8 6; }
.edge-line.dotted { stroke-dasharray: 2 6; stroke-linecap: round; }
.edge-hit { fill: none; stroke: transparent; stroke-width: 14; cursor: pointer; }
.node { cursor: pointer; transition: opacity .15s; }
.node rect, .legend-node rect {
fill: #f1f2f2;
stroke: #858b90;
stroke-width: 1.2;
rx: 7;
transition: fill .15s, stroke .15s, stroke-width .15s;
}
.node text {
fill: var(--ink);
font: 14px ui-monospace, SFMono-Regular, Consolas, monospace;
text-anchor: middle;
pointer-events: none;
}
.node .stream-mark { fill: var(--muted); font: 11px system-ui, sans-serif; }
.is-filtered, .is-context-dim { opacity: .16; }
.is-path:not(.is-filtered), .is-hover:not(.is-filtered) { opacity: 1; }
.is-path .edge-line, .edge.is-path .edge-line {
stroke: var(--accent); stroke-width: 2.8; marker-end: url(#arrowhead-active);
}
.node.is-path rect { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
.node.is-hover rect { fill: #faefeb; stroke: #d79078; }
.edge.is-hover .edge-line { stroke: #d79078; stroke-width: 2.2; }
.node.is-selected rect { stroke-width: 3; }
.edge.is-selected .edge-line { stroke-width: 4; }
aside {
min-height: 470px; max-height: calc(100vh - 32px); overflow-y: auto; position: sticky; top: 16px;
padding: 20px;
border: 1px solid #d7d9da;
border-radius: 10px;
background: var(--panel);
}
@media (max-width: 900px) { main { grid-template-columns: minmax(0, 1fr); } aside { min-height: 0; max-height: none; position: static; } .map-scroll { max-height: 50vh; overflow: auto; } }
aside h2 { margin: 0 0 3px; font-size: 20px; overflow-wrap: anywhere; }
.meta { margin: 0 0 18px; color: var(--muted); }
.card-section { margin-top: 17px; }
.card-section h3 { margin: 0 0 5px; font-size: 13px; }
.card-section p { margin: 0; }
.card-section ul { margin: 5px 0 0; padding-left: 20px; }
.card-section li + li { margin-top: 5px; }
code { font: .92em ui-monospace, SFMono-Regular, Consolas, monospace; }
a { color: #8a351e; text-underline-offset: 2px; }
.hint { color: var(--muted); font-size: 13px; }
.edge[hidden] { display: none; }
.node.student rect, .legend-node.student rect { stroke-dasharray: 5 3; }
.node.student .stream-mark { fill: #875a14; }
.edge.expected .edge-line, .legend-arrow.expected .edge-line { stroke-dasharray: 5 4; }
.node.is-upstream rect, .legend-node.is-upstream rect { fill: #e3eff5; stroke: #307c9a; }
.node.is-downstream rect, .legend-node.is-downstream rect { fill: #e7f1e5; stroke: #527a43; }
.node.is-selected rect, .legend-node.is-selected rect { fill: var(--accent-soft); stroke: var(--accent); }
.relation-button { border-radius: 5px; padding: 5px 7px; text-align: left; width: 100%; margin-top: 5px; font-size: 13px; }
.badge { font-size: 13px; padding: 3px 8px; border-radius: 4px; background: #eef1ed; display: inline-block; }
.badge.student { background: #fff1cf; color: #775018; }
.navigation { margin: 8px 0; }
code { overflow-wrap: anywhere; }
summary { cursor: pointer; font-weight: 600; }
#view-hint { margin: 0; max-width: 1050px; }
noscript { display: block; padding: 20px; }
</style>
</head>
<body>
<header>
<h1 id="page-title"></h1>
<p class="lead" id="lead"></p>
<p class="navigation"><a href="__GUIDE_URL__" target="_blank" rel="noopener noreferrer">Руководство: от бронирования до витрины (Gitea)</a> · Учебная ветка main</p>
</header>
<div class="controls">
<div class="switcher" id="switcher" aria-label="Вид связей"></div>
<p id="view-hint" class="hint"></p>
<div class="legend" aria-label="Обозначения стрелок">
<span class="legend-item">
<svg class="legend-icon legend-arrow" viewBox="0 0 50 20" aria-hidden="true"><path class="edge-line" d="M 2 10 H 43"/></svg>
<span id="ready-edge-legend">Готовая загрузка</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-arrow expected" viewBox="0 0 50 20" aria-hidden="true"><path class="edge-line" d="M 2 10 H 43"/></svg>
<span id="expected-edge-legend">Загрузка по ТЗ</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-arrow is-path" viewBox="0 0 50 20" aria-hidden="true"><path class="edge-line" d="M 2 10 H 43"/></svg>
<span>Выделенные связи</span>
</span>
</div>
<div class="legend" aria-label="Обозначения таблиц">
<span class="legend-item">
<svg class="legend-icon legend-node" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span>Готовая таблица</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node student" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span>Учебная заглушка</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node is-upstream" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span id="upstream-legend">Источники</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node is-downstream" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span id="downstream-legend">Потребители</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node is-selected" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span>Выбранная таблица</span>
</span>
</div>
</div>
<main>
<div class="map-scroll">
<svg id="map" viewBox="0 0 1250 725" role="group" aria-label="Путь данных через слои хранилища"></svg>
</div>
<aside id="details" aria-live="polite" aria-label="Карточка выбранного объекта"></aside>
</main>
<noscript>Для интерактивной карты включите JavaScript. Объяснение модели доступно в <a href="__GUIDE_URL__">руководстве в Gitea</a>.</noscript>
<script>
const MAP = /*__MAP_DATA__*/;
const NS = "http://www.w3.org/2000/svg";
const state = { view: "flow", selected: null };
const nodeById = new Map(MAP.nodes.map(node => [node.id, node]));
const edgeById = new Map(MAP.edges.map(edge => [edge.id, edge]));
const positions = new Map();
const svg = document.getElementById("map");
const details = document.getElementById("details");
const statusLabel = {ready: "Готовая загрузка", student: "Задание · загрузка и DQ — заглушки", source: "Источник · демо-БД"};
function svgElement(name, attributes = {}) {
const element = document.createElementNS(NS, name);
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value));
return element;
}
function layerTitle(layerId) { return MAP.layers.find((layer) => layer.id === layerId)?.title ?? ""; }
function appendRichText(parent, value) {
value.split("`").forEach((part, index) => {
const element = index % 2 ? document.createElement("code") : document.createTextNode(part);
if (index % 2) element.textContent = part;
parent.append(element);
});
}
function addSection(title, value) {
if (!value || (Array.isArray(value) && !value.length)) return;
const section = document.createElement("section");
section.className = "card-section";
const heading = document.createElement("h3");
heading.textContent = title;
section.append(heading);
if (Array.isArray(value)) {
const list = document.createElement("ul");
value.forEach((item) => {
const row = document.createElement("li");
appendRichText(row, item);
list.append(row);
});
section.append(list);
} else {
const paragraph = document.createElement("p");
appendRichText(paragraph, value);
section.append(paragraph);
}
details.append(section);
}
function addLinks(links) {
if (!links?.length) return;
const section = document.createElement("section");
section.className = "card-section";
const heading = document.createElement("h3");
heading.textContent = "Ссылки";
const list = document.createElement("ul");
links.forEach((item) => {
const row = document.createElement("li");
const link = document.createElement("a");
// Выбор таблицы остается в локальной карте; файлы открываются в Gitea.
link.href = item.url.startsWith("#") ? item.url : new URL(item.url, new URL("docs/design/", MAP.repository_url)).href;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = item.label;
row.append(link);
list.append(row);
});
section.append(heading, list);
details.append(section);
}
function renderIntro(message = "") {
details.replaceChildren();
const heading = document.createElement("h2");
heading.textContent = message || "Начните с билета";
details.append(heading);
addSection("Маршрут чтения", "Один билет может включать несколько перелетов. Выберите bookings.tickets, затем проследите сегменты до факта и витрины sales_report.");
addSection("Как исследовать", "Выбор таблицы подсвечивает ее окружение: источники и потребителей в потоке данных, родителей и потомков в связях по ключам. Их положение остается прежним. Соседние связи доступны кнопками в карточке. Повторный выбор, Esc или «Общий вид» снимает выделение.");
addSection("Что показывает карта", "Стрелки описывают данные. Порядок задач Airflow смотрите отдельно по ссылке на DAG в карточке. Пунктирная стрелка означает ожидаемую загрузку из задания.");
addSection("Вход через PXF", "Внешние stg.*_ext показаны внутри карточек STG: они читают Postgres, но сами не хранят копию данных.");
addSection("Ключи", "Все ключи и связи здесь логические. DDL таблиц DWH не объявляет PRIMARY KEY, UNIQUE и FOREIGN KEY. DISTRIBUTED BY задает размещение, а не уникальность.");
addLinks([{label: "Открыть руководство о модели", url: "db_schema.md"}]);
}
function relationButtons(node) {
const section = document.createElement("section");
section.className = "card-section";
const h = document.createElement("h3"); h.textContent = "Связи рядом с таблицей";
section.append(h);
const edges = MAP.edges.filter(e => e.mode === state.view && (e.from === node.id || e.to === node.id));
edges.forEach(edge => {
const b = document.createElement("button"); b.className = "relation-button";
const neighbor = edge.from === node.id ? edge.to : edge.from;
b.textContent = `${edge.from === node.id ? "→" : "←"} ${neighbor}: ${edge.label}`;
b.dataset.edge = edge.id;
b.addEventListener("click", () => selectItem("edge", edge.id));
section.append(b);
});
if (!edges.length) {
const p = document.createElement("p"); p.textContent = "В этом виде рядом нет связей. Переключите вид, чтобы увидеть путь загрузки."; section.append(p);
}
details.append(section);
}
function renderNodeCard(node) {
details.replaceChildren();
const heading = document.createElement("h2"); heading.textContent = node.name;
const meta = document.createElement("p"); meta.className = `badge ${node.status}`; meta.textContent = statusLabel[node.status];
details.append(heading, meta);
addSection("Назначение", node.what);
addSection("Одна строка", node.grain);
addSection("Логические ключи", node.keys);
addSection(node.status === "student" ? "Ожидаемый результат по ТЗ" : "Как наполняется", node.fill);
if (node.parts.length) addSection("Вход через PXF", node.parts.map(id => `\`${id}\` — внешняя таблица для чтения Postgres, без локального хранения. Ее определение находится в том же DDL.`));
const fields = document.createElement("details"); fields.className = "card-section";
const summary = document.createElement("summary"); summary.textContent = `Важные поля (${node.fields.length})`;
const list = document.createElement("ul");
node.fields.forEach(field => {
const li = document.createElement("li"); appendRichText(li, "`" + field.name + "` — " + field.meaning); list.append(li);
});
fields.append(summary,list); details.append(fields);
relationButtons(node);
const dag = {stg:"stage",ods:"ods",dds:"dds",dm:"dm"}[node.layer];
addLinks([...node.links, ...(dag ? [{label:"DAG · порядок задач",url:`../../airflow/dags/bookings_to_gp_${dag}.py`}] : []), {label:"Ссылка на эту таблицу",url:`#node=${node.id}&view=${state.view}`}]);
}
function renderEdgeCard(edge) {
details.replaceChildren();
const heading = document.createElement("h2"); heading.textContent = edge.label; details.append(heading);
addSection("Таблицы", "`" + edge.from + "` → `" + edge.to + "`");
addSection("Вид связи", edge.mode === "flow" ? (edge.status === "student" ? "Ожидаемый поток по ТЗ; загрузка пока не реализована." : "Чтение и преобразование данных в готовом SQL.") : "Логическое сопоставление строк по ключам. Это не зависимость задач Airflow и не объявленное ограничение БД.");
addSection("Что связывает", edge.join);
addSection("Участвующие поля", edge.refs.map(r => `\`${r.node}\`: ${r.columns.map(c => "`"+c+"`").join(", ")}`));
[edge.from, edge.to].forEach(id => {
const b = document.createElement("button"); b.className = "relation-button"; b.textContent = `Открыть ${id}`;
b.addEventListener("click", () => selectItem("node", id)); details.append(b);
});
addLinks(edge.links);
}
function layoutMap() {
MAP.layers.forEach((layer, index) => {
MAP.nodes.filter(node => node.layer === layer.id).forEach(node => {
positions.set(node.id, {x: 20 + index * 250, y: 72 + node.order * 70, width: 195, height: 48});
});
});
}
function edgePath(edge, offset) {
const a = positions.get(edge.from), b = positions.get(edge.to);
const y1 = a.y + a.height / 2 + offset, y2 = b.y + b.height / 2 + offset;
if (a.x === b.x) {
const x = a.x + a.width, bend = x + 20 + Math.abs(a.y - b.y) * .08 + offset;
return `M ${x} ${y1} C ${bend} ${y1}, ${bend} ${y2}, ${x} ${y2}`;
}
const x1 = a.x + a.width, x2 = b.x, bend = Math.max(18, (x2 - x1) / 2);
return `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`;
}
function drawMap() {
const defs = svgElement("defs");
[["arrowhead", "#8c9298"], ["arrowhead-active", "#b54a2a"]].forEach(([id, fill]) => {
const marker = svgElement("marker", {id, viewBox:"0 0 8 8", refX:"7", refY:"4", markerWidth:"6", markerHeight:"6", orient:"auto"});
marker.append(svgElement("path", {d:"M 0 0 L 8 4 L 0 8 z", fill})); defs.append(marker);
});
svg.append(defs);
MAP.layers.forEach((layer,index) => {
const center = 117.5 + index * 250;
const title = svgElement("text", {x:center, y:27, class:"layer-title"}); title.textContent = layer.title;
const caption = svgElement("text", {x:center, y:47, class:"layer-caption"}); caption.textContent = layer.caption;
svg.append(title,caption);
});
MAP.edges.forEach(edge => {
const siblings = MAP.edges.filter(e => e.from === edge.from && e.to === edge.to && e.mode === edge.mode);
const offset = (siblings.indexOf(edge) - (siblings.length - 1) / 2) * 13;
const group = svgElement("g", {class:`edge ${edge.status === "student" ? "expected" : ""}`, "data-id":edge.id, tabindex:"0", role:"button", "aria-label":`${edge.label}: ${edge.from} → ${edge.to}`});
const d = edgePath(edge,offset);
group.append(svgElement("path", {d,class:"edge-line"}),svgElement("path", {d,class:"edge-hit"}));
bindSelection(group,"edge",edge.id); svg.append(group);
});
MAP.nodes.forEach(node => {
const p = positions.get(node.id);
const g = svgElement("g", {class:`node ${node.status}`, "data-id":node.id, tabindex:"0", role:"button", "aria-label":`${node.name}: ${statusLabel[node.status]}`});
g.append(svgElement("rect",p));
const label = svgElement("text", {x:p.x+p.width/2,y:p.y+21}); label.textContent = node.name;
const mark = svgElement("text", {x:p.x+p.width/2,y:p.y+38,class:"stream-mark"}); mark.textContent = {ready:"готово",student:"задание",source:"источник"}[node.status];
g.append(label,mark); bindSelection(g,"node",node.id); svg.append(g);
});
}
function bindSelection(element,type,id) {
element.addEventListener("click", () => selectItem(type,id));
element.addEventListener("keydown", event => {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectItem(type,id); }
});
}
function walk(nodeId,direction) {
const nodes = new Set([nodeId]), edges = new Set(), queue = [nodeId];
while (queue.length) {
const current = queue.shift();
MAP.edges.filter(e => e.mode === state.view).forEach(edge => {
if ((direction === "in" ? edge.to : edge.from) !== current) return;
edges.add(edge.id);
const next = direction === "in" ? edge.from : edge.to;
if (!nodes.has(next)) { nodes.add(next); queue.push(next); }
});
}
return {nodes,edges};
}
function updateHighlights() {
let upstream = new Set(), downstream = new Set(), activeEdges = new Set(), activeNodes = new Set();
if (state.selected?.type === "node") {
const a = walk(state.selected.id,"in"), b = walk(state.selected.id,"out");
upstream = a.nodes; downstream = b.nodes;
activeNodes = new Set([...upstream,...downstream]); activeEdges = new Set([...a.edges,...b.edges]);
} else if (state.selected?.type === "edge") {
const edge = edgeById.get(state.selected.id);
activeNodes = new Set([edge.from,edge.to]); activeEdges.add(edge.id);
}
svg.querySelectorAll(".node").forEach(el => {
const id = el.dataset.id, selected = state.selected?.type === "node" && state.selected.id === id;
el.classList.toggle("is-context-dim", Boolean(state.selected) && !activeNodes.has(id));
el.classList.toggle("is-upstream", upstream.has(id)); el.classList.toggle("is-downstream", downstream.has(id));
el.classList.toggle("is-selected", selected); el.setAttribute("aria-pressed",String(selected));
});
svg.querySelectorAll(".edge").forEach(el => {
const edge = edgeById.get(el.dataset.id), visible = edge.mode === state.view;
el.toggleAttribute("hidden", !visible); el.setAttribute("tabindex",visible ? "0" : "-1");
el.setAttribute("aria-hidden",String(!visible));
el.classList.toggle("is-context-dim",Boolean(state.selected) && !activeEdges.has(edge.id));
el.classList.toggle("is-path",activeEdges.has(edge.id));
const selected = state.selected?.type === "edge" && state.selected.id === edge.id;
el.classList.toggle("is-selected", selected); el.setAttribute("aria-pressed",String(selected));
});
}
function renderState(message = "") {
document.querySelectorAll("[data-view]").forEach(b => b.setAttribute("aria-pressed",String(b.dataset.view === state.view)));
document.getElementById("upstream-legend").textContent = state.view === "flow" ? "Источники" : "Связанные родители";
document.getElementById("downstream-legend").textContent = state.view === "flow" ? "Потребители" : "Связанные потомки";
document.getElementById("ready-edge-legend").textContent = state.view === "flow" ? "Готовая загрузка" : "Связь по ключам";
document.getElementById("expected-edge-legend").textContent = state.view === "flow" ? "Загрузка по ТЗ" : "Связь с учебной заглушкой";
document.getElementById("view-hint").textContent = state.view === "flow" ? "Стрелка: откуда загрузчик читает данные и куда записывает результат. Календарь генерируется отдельно." : "Стрелка: от родительской строки к связанным строкам. Например, от бронирования к билетам. Это не порядок загрузки и не физические FK.";
if (state.selected?.type === "node") renderNodeCard(nodeById.get(state.selected.id));
else if (state.selected?.type === "edge") renderEdgeCard(edgeById.get(state.selected.id));
else renderIntro(message);
details.scrollTop = 0; updateHighlights();
}
function saveAddress() {
const p = new URLSearchParams();
if (state.selected) p.set(state.selected.type,state.selected.id);
if (state.view !== "flow") p.set("view",state.view);
const hash = p.toString();
if (location.hash.slice(1) !== hash) location.hash = hash;
renderState();
}
function selectItem(type,id) {
const repeated = state.selected?.type === type && state.selected.id === id;
state.selected = repeated ? null : {type,id}; saveAddress();
}
function readAddress() {
const p = new URLSearchParams(location.hash.slice(1));
state.view = p.get("view") === "keys" ? "keys" : "flow";
state.selected = null;
if (nodeById.has(p.get("node"))) state.selected = {type:"node",id:p.get("node")};
else if (edgeById.has(p.get("edge"))) {
const edge = edgeById.get(p.get("edge")); state.view = edge.mode; state.selected = {type:"edge",id:edge.id};
}
renderState(!state.selected && (p.has("node") || p.has("edge")) ? "Объект по ссылке не найден. Выберите таблицу на карте." : "");
}
function renderControls() {
const switcher = document.getElementById("switcher");
[["flow","Поток данных"],["keys","Связи по ключам"]].forEach(([id,label]) => {
const b = document.createElement("button"); b.textContent = label; b.dataset.view = id;
b.addEventListener("click", () => {
state.view = id;
if (state.selected?.type === "edge") state.selected = {type:"node",id:edgeById.get(state.selected.id).to};
saveAddress();
}); switcher.append(b);
});
const clear = document.createElement("button"); clear.textContent = "Общий вид"; clear.id = "clear-selection";
clear.addEventListener("click", () => {state.selected = null; saveAddress();}); switcher.append(clear);
}
document.getElementById("page-title").textContent = MAP.title;
document.getElementById("lead").textContent = MAP.lead;
layoutMap(); drawMap(); renderControls(); readAddress();
window.addEventListener("hashchange",readAddress);
document.addEventListener("keydown",event => {
if (event.key === "Escape" && state.selected) {state.selected = null; saveAddress();}
});
</script>
</body>
</html>
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` для всех строк
+728
View File
@@ -0,0 +1,728 @@
# 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`, `routes`) пустой батч считаем ошибкой.
На main `airplanes` и `seats` — студенческие заглушки (load/dq = `SELECT 1;`),
таблицы будут пустыми до реализации студентом.
---
## 7) DQ-проверки ODS (минимум, но строго)
Каждый DQ-скрипт должен:
- быть привязан к `stg_batch_id` (через XCom, как в load-скриптах);
- делать `RAISE EXCEPTION` при нарушении;
- давать понятную подсказку в тексте ошибки.
### 7.1. Обязательные проверки
1. **Нет дублей** по бизнес-ключу в ODS.
2. **Покрытие батча:** все ключи из STG текущего батча присутствуют в ODS.
3. **Обязательные поля** не `NULL`/не пустые.
4. **Батч не пустой** для эталонных snapshot-справочников (`airports`, `routes`): если STG-батч оказался пустым — это ошибка (источник недоступен или PXF не работает).
На main `airplanes_dq.sql` и `seats_dq.sql` — студенческие заглушки.
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` (FK: `departure_airport`, `arrival_airport`).
На ветке `solution` также зависит от `airplanes` (FK: `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`.
+142
View File
@@ -0,0 +1,142 @@
# Дизайн STG для bookings в Greenplum
## 1. Цель и общий контур
- Источник: Postgres в контейнере `bookings-db`, база `demo`, таблица `bookings.bookings`.
- Цель: показываем путь данных от операционной БД до сырого слоя 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. Связь с остальными документами
- Детали настройки PXF и часовых поясов — в ветке `solution` (`docs/reference/`).
- `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.
+217
View File
@@ -0,0 +1,217 @@
# Как устроено хранилище авиаперевозок
Разберем одну поездку: как бронирование связано с билетами и перелетами,
какие строки попадут в факт и как сложится выручка по направлениям.
Найти эти строки в структуре хранилища поможет [интерактивная карта](architecture-map.html).
Карта хранит подробности объектов и связей, а это руководство задает маршрут чтения.
## Как открыть карту
Из локальной копии репозитория откройте `docs/design/architecture-map.html`
в браузере: через файловый менеджер или командой из корня проекта:
```bash
xdg-open docs/design/architecture-map.html
```
На Windows можно открыть этот же файл в Проводнике. Сервер, интернет и работающий
Docker для карты не нужны: все ее данные включены в HTML.
Ссылки на SQL, DAG, задание и это руководство открывают файлы в Gitea:
документы — с форматированием, код — с подсветкой синтаксиса.
Для этих переходов нужен интернет. Файлы берутся из текущей ветки репозитория
по умолчанию.
**В Gitea ссылка на HTML обычно показывает исходный текст, а не карту.**
Сначала откройте локальный HTML. Адресная ссылка вида
`architecture-map.html#node=dds.fact_flight_sales` сразу выбирает таблицу;
ее можно открыть из локального просмотра этого руководства или добавить
часть после `#` к адресу уже открытой карты.
## Бронирование, билет и перелет
Ниже **учебный пример**, придуманный для объяснения. Это не выгрузка из стенда.
Короткие обозначения `T1`, `F101`, `R1` заменяют настоящие номера.
Аня и Борис купили билеты в одном бронировании `B1` от 30 августа.
Аня летит 1 сентября из Москвы через Петербург в Казань. Борис летит
тем же первым рейсом до Петербурга. Все сегменты - Economy.
| Таблица источника | Пример строк | Что различают строки |
|---|---|---|
| `bookings.bookings` | `B1`, сумма `12000` | Одно бронирование на всю покупку |
| `bookings.tickets` | `T1 → B1, Аня`; `T2 → B1, Борис` | Два билета, по одному на пассажира |
| `bookings.segments` | `(T1, F101), 3000`; `(T1, F102), 5000`; `(T2, F101), 4000` | Три оплаченных перелета |
| `bookings.flights` | `F101 → R1`, 1 сентября; `F102 → R2`, 1 сентября | Два конкретных рейса |
| `bookings.routes` | `R1: SVO → LED`; `R2: LED → KZN` | Маршруты и варианты их расписания |
Бронирование объединяет покупку. Билет принадлежит пассажиру и может включать
несколько сегментов. Сегмент соединяет билет с конкретным рейсом и хранит цену
этого перелета. Поэтому `ticket_no` недостаточно для поиска сегмента:
нужна пара `(ticket_no, flight_id)`.
Рейс и маршрут тоже различаются. `flight_id` обозначает один вылет,
а `route_no` повторяется у рейсов в разные дни. В `routes` у одного номера
могут быть разные варианты расписания; строку различает пара
`(route_no, validity)`.
Откройте [билеты](architecture-map.html#node=bookings.tickets&view=keys)
в виде **"Связи по ключам"**. Выделены бронирование, сегменты и связанные с ними
талоны. Выберите связь билета с сегментами: в карточке виден `ticket_no`.
Для связи сегмента с талоном понадобятся оба поля ключа, иначе талон
первого рейса можно ошибочно присоединить ко второму.
<a id="как-устроен-поток-целиком"></a>
## Путь данных от источника до витрин
Оставьте билет выбранным и переключитесь на **"Поток данных"**.
Таблицы останутся на местах, выбранный билет сохранится; теперь стрелки
показывают чтение данных загрузчиками. Синий цвет отмечает источники выбранной
таблицы, зеленый - потребителей, оранжевый - сам выбор. Несвязанные объекты
остаются видны приглушенными. Повторный выбор, **"Общий вид"** или `Esc`
возвращает обзор.
Проследите путь
[сегмента](architecture-map.html#node=bookings.segments) через STG и ODS к факту:
- Источник → STG: PXF читает Postgres через внешние `stg.*_ext`.
Эти входы описаны в карточках STG и сами не хранят копию. Загрузчики
сохраняют строки в `stg.*` с бизнес-полями в TEXT и метками загрузки.
- STG → ODS: У строки появляются нужные типы. Например,
`stg.segments.price` становится `ods.segments.amount` типа NUMERIC.
ODS хранит текущее состояние строки по бизнес-ключу.
- ODS → DDS → DM: Сегмент обогащается ключами измерений в факте,
затем участвует в агрегатах витрины.
Стрелка данных не означает очередность задач Airflow. Например, календарь
генерируется без чтения ODS, хотя в DDS DAG задачи других измерений ждут
проверки календаря. Порядок выполнения смотрите по ссылке **"DAG · порядок задач"**
в карточке. Поля, SQL и проверки раскрываются там же, после выбора таблицы.
<a id="как-собирается-факт"></a>
## Сборка факта продаж
Откройте [dds.fact_flight_sales](architecture-map.html#node=dds.fact_flight_sales).
В нашем примере в нем будет **три строки**, по одной на каждый сегмент.
Важные значения до выполнения студенческих заданий выглядят так:
| ticket_no | flight_id | price | departure_airport_sk | arrival_airport_sk | route_sk | airplane_sk | passenger_sk |
|---|---|---:|---|---|---|---|---|
| T1 | F101 | 3000 | SK аэропорта SVO | SK аэропорта LED | NULL | NULL | NULL |
| T1 | F102 | 5000 | SK аэропорта LED | SK аэропорта KZN | NULL | NULL | NULL |
| T2 | F101 | 4000 | SK аэропорта SVO | SK аэропорта LED | NULL | NULL | NULL |
SK здесь обозначены словами: конкретные числа назначает загрузка измерений.
`calendar_sk` указывает на 1 сентября, `tariff_sk` - на Economy.
`book_ref = B1` повторяется во всех трех строках; `book_date` - 30 августа.
Цена хранится на уровне сегмента, а сумма `12000` из бронирования в факт
не переносится. Иначе ее можно было бы ошибочно посчитать трижды.
Начальный поток - `ods.segments`. Соединения с билетами, бронированиями
и рейсами - `INNER JOIN`: отсутствие подходящей строки исключит сегмент
из вставки факта. Это потеря покрытия, которую должна обнаружить DQ.
Посадочный талон добавляется через `LEFT JOIN` по паре `(ticket_no, flight_id)`.
Нет талона - сегмент сохраняется, `seat_no` остается `NULL`, `is_boarded = false`.
В этом стенде признак посадки означает именно наличие талона.
<a id="откуда-берутся-два-аэропорта"></a>
### Аэропорты вылета и прилета
В карточке факта откройте связи **"Коды двух аэропортов"**,
**"Аэропорт вылета"** и **"Аэропорт прилета"**:
1. Рейс дает `route_no`. Из `ods.routes` берется одна строка с наибольшим
`validity` на этот номер. В ODS поле `validity` хранится как TEXT;
этот выбор не использует дату рейса.
2. `departure_airport` и `arrival_airport` дают два кода аэропорта.
3. Один `dds.dim_airports` присоединяется дважды по `airport_bk`, в ролях
вылета и прилета. Получаются два разных поля SK в факте.
Этот путь работает без студенческого `dim_routes`. Отсутствующий аэропорт
в факте - аномалия данных, а не ожидаемый результат невыполненного задания.
SQL использует `LEFT JOIN`, поэтому строка с таким пропуском сохранится;
допустимость пропуска отдельно оценивает [DQ факта](../../sql/dds/fact_flight_sales_dq.sql).
<a id="какая-версия-маршрута-нужна-рейсу"></a>
### Версия маршрута на дату рейса
Связь **"Версия на дату рейса"** ведет к `dds.dim_routes` по `route_bk = route_no`
и дате `scheduled_departure::DATE`. Дата должна попасть в полуоткрытый
интервал `[valid_from, valid_to)`; `NULL` в конце означает открытую версию.
Рейс на границе двух версий относится к новой.
Из выбранной версии берется `route_sk`, а ее `airplane_code` нужен для
поиска `airplane_sk` в `dim_airplanes`. Поэтому одного заполненного справочника
моделей недостаточно: без подходящей версии маршрута `airplane_sk` не появится.
<a id="что-кормит-витрины"></a>
## Агрегация фактов в витрине продаж
Перейдите к [dm.sales_report](architecture-map.html#node=dm.sales_report).
Зерно меняется: день вылета × два аэропорта × класс обслуживания.
Три строки примера дадут две строки витрины:
| Дата вылета | Направление | Класс | tickets_sold | total_revenue |
|---|---|---|---:|---:|
| 1 сентября | SVO → LED | Economy | 2 | 7000 |
| 1 сентября | LED → KZN | Economy | 1 | 5000 |
`tickets_sold` здесь считает **сегменты**. Один билет может участвовать несколько раз.
Если сложить его по направлениям, получится 3 при двух билетах.
`total_revenue` складывается из цен сегментов, поэтому итог равен 12000.
Доля посадок рассчитывается по `is_boarded`.
Готовая витрина использует календарь, две роли аэропорта и тариф через
`INNER JOIN`: факт без совпадения в одном из этих измерений не попадет в агрегат.
Поля `route_sk`, `airplane_sk`, `passenger_sk` ей не требуются, поэтому
эталон работает до выполнения заданий. Остальные витрины видны рядом
пунктирными рамками: в их карточках описан требуемый результат по ТЗ.
## Модели самолетов и места: начало задания
Откройте [dds.dim_airplanes](architecture-map.html#node=dds.dim_airplanes).
У него два источника: `ods.airplanes` дает характеристики модели,
а `ods.seats` - места, по которым считается вместимость.
Строка измерения описывает **модель**, например тип самолета, а не отдельный борт.
`seat_no` уникален только вместе с `airplane_code`.
На `main` STG уже заполняется, а загрузки `ods.airplanes`, `ods.seats`
и `dds.dim_airplanes` пока содержат `SELECT 1;`. Карточки помечены
**"Задание"** и ведут к соответствующим пунктам ТЗ. Ожидаемые поля
известны из DDL, но готовой загрузки этих объектов еще нет.
## Ключевые договорённости
- Ключи на карте логические. В DDL таблиц DWH нет ограничений
`PRIMARY KEY`, `UNIQUE` и `FOREIGN KEY`; уникальность и связи проверяют SQL
и DQ. `DISTRIBUTED BY` отвечает за размещение строк.
- `_load_id` и `_load_ts` описывают загрузку. `event_ts` - ее опорное время;
у снимков справочников в STG оно равно времени получения снимка.
Подробности - в [соглашениях об именах](naming_conventions.md).
- До реализации измерений допустимы `NULL` в `passenger_sk`, `route_sk`
и `airplane_sk`. Ключи фиксируются **при вставке** факта: повторный запуск
меняет цену и сведения о посадке, но не заполняет старые SK.
После выполнения заданий следуйте разделу
["Пересчет факта после реализации измерений"](../assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений).
- В карточке заглушки описан результат из актуального ТЗ. Если ТЗ не определяет
тип соединения или поведение при пропуске строки, карта отмечает этот предел,
не выдает предположение за работающий SQL.
## Куда идти дальше
Для первого задания разберите готовую загрузку справочника:
[как читать SQL загрузки](reading_the_pipeline.md), затем откройте
[учебные задания](../assignment/README.md). Для точечного вопроса
о таблице возвращайтесь на карту: там находится каталог объектов,
их источников, потребителей, SQL и DQ.
Для проверки результата пригодится [справочник DQ](../reference/dq_taxonomy.md).
Технические материалы по слоям остаются доступны:
[STG](bookings_stg_design.md), [ODS](bookings_ods_design.md),
[DDS](bookings_dds_design.md), [DM](bookings_dm_design.md).
В них есть планы разработки; текущее поведение проверяйте по SQL,
а требования к самостоятельной работе - по ТЗ.
+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`.
+66
View File
@@ -0,0 +1,66 @@
# Как читать пайплайн по скрипту загрузки
Большую схему не нужно запоминать. Связи любой таблицы уже записаны в блоке
`FROM ... JOIN ...` её файла `*_load.sql`. Обычно каждая прочитанная таблица
даёт одно входящее ребро. Если одна таблица читается под разными псевдонимами,
как `dim_airports`, её можно показать одним узлом.
## Метод
`INNER JOIN` показывает обязательную часть основного потока. Если входной строки
нет, целевая строка не появится. `LEFT JOIN` к измерениям решает две задачи.
Факт может прийти раньше измерения: это опаздывающее измерение
(late-arriving dimension). Эталонный факт также не должен зависеть от того,
успел ли студент реализовать свои измерения. Поэтому строка факта сохраняется,
а её суррогатный ключ пока может быть `NULL`.
В этом CTE есть и другие `LEFT JOIN`. `ods.routes` даёт эталонный путь к
аэропортам, а `ods.boarding_passes` содержит необязательные `seat_no` и
признак посадки.
Если в загрузчике несколько SQL-операторов, смотрите на источник `INSERT`.
Именно он задаёт полный список входящих связей для новых строк. Например, в
`fact_flight_sales_load.sql` блок `FROM` у `UPDATE` читает только изменяемые
поля, а полный список находится в CTE `fact_src` перед `INSERT`.
## Пример: `fact_flight_sales`
Откройте [`sql/dds/fact_flight_sales_load.sql`](../../sql/dds/fact_flight_sales_load.sql)
и пройдите `fact_src` сверху вниз:
1. `FROM ods.segments` задаёт зерно `(ticket_no, flight_id)` и поле `price`.
2. `JOIN ods.tickets` обязателен для `book_ref` и `passenger_id`.
3. `JOIN ods.bookings` обязателен для `book_date`.
4. `JOIN ods.flights` обязателен для расписания и `route_no`.
5. `LEFT JOIN ods.routes` находит аэропорты по `route_no`. Этот эталонный путь
не зависит от студенческого `dim_routes`.
6. `LEFT JOIN dds.dim_routes` ищет point-in-time версию маршрута и `route_sk`.
7. `LEFT JOIN dds.dim_calendar` даёт `calendar_sk` по дате вылета.
8. Два `LEFT JOIN dds.dim_airports` дают ключи аэропортов вылета и прилёта.
На схеме их можно показать одним узлом `dim_airports`.
9. `LEFT JOIN dds.dim_airplanes` даёт `airplane_sk` через найденную версию маршрута.
10. `LEFT JOIN dds.dim_tariffs` даёт `tariff_sk`.
11. `LEFT JOIN dds.dim_passengers` даёт `passenger_sk`.
12. `LEFT JOIN ods.boarding_passes` даёт `seat_no` и признак `is_boarded`.
Этот путь разобран в разделе [«Сборка факта продаж»](db_schema.md#сборка-факта-продаж).
На карте сплошные стрелки показывают готовые загрузки, пунктирные — ожидаемые
по ТЗ. Тип соединения и поведение при отсутствии строки описаны в карточке связи.
## Как проверить свой слой
- Выпишите все таблицы из `FROM` и `JOIN` своего `*_load.sql`.
- Сравните список с эталонной схемой или дизайн-документом этого слоя.
- Проверьте служебные и SCD-поля по [`naming_conventions.md`](naming_conventions.md).
- Убедитесь, что рядом есть `*_dq.sql` и он покрывает нужные
[классы DQ-проверок](../reference/dq_taxonomy.md).
## Шаблон схемы одной таблицы
Скопируйте шаблон и замените имена таблиц и полей:
```mermaid
graph LR
MAIN["schema.main_input"] -->|"grain, metric"| TARGET["schema.target"]
LOOKUP["schema.lookup"] -.->|"lookup_key"| TARGET
```
+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 (нужен запуск стенда)
+63
View File
@@ -0,0 +1,63 @@
# Классы DQ-проверок
DQ-проверка должна отвечать на конкретный вопрос. В этом проекте есть три
класса. Один `*_dq.sql` может содержать проверки нескольких классов.
## 1. Универсальные структурные проверки
Они проверяют форму таблицы: обязательные ключи заполнены, ключ уникален, а
таблица не пуста там, где по контракту ожидаются строки. Так можно быстро поймать
сломанное зерно или незавершённую загрузку без знания бизнес-смысла данных.
Реальные примеры:
- [`sql/dds/dim_airports_dq.sql`](../../sql/dds/dim_airports_dq.sql) требует
непустую таблицу, проверяет уникальность `airport_sk` и `airport_bk`, а также
обязательные поля.
- [`sql/ods/bookings_dq.sql`](../../sql/ods/bookings_dq.sql) ищет дубли
`book_ref` и `NULL` или пустые значения в обязательных полях. Пустой
инкрементальный батч здесь допустим.
- [`sql/ods/tickets_dq.sql`](../../sql/ods/tickets_dq.sql) проверяет уникальность
`ticket_no`, обязательные поля и отсутствие билетов без `bookings.book_ref`.
## 2. Проверки бизнес-правил
Они проверяют ограничения предметной области, которых не видно из структуры
таблицы. Такая проверка нужна, чтобы заметить правдоподобную по типам, но
невозможную по смыслу строку.
В [`sql/dm/sales_report_dq.sql`](../../sql/dm/sales_report_dq.sql) для дат
текущего запуска проверяется, что `tickets_sold >= passengers_boarded`,
`boarding_rate` лежит между `0` и `1`, а `total_revenue` неотрицательна.
## 3. Межслойные сверки
Они отвечают на вопрос: согласован ли слой N+1 со слоем N для нужного батча?
Сверка находит потерянные или лишние строки на границе загрузки.
Реальные примеры используют разные способы сверки:
- [`sql/ods/airports_dq.sql`](../../sql/ods/airports_dq.sql) сравнивает множества
`airport_code`: ищет пропущенные и лишние ключи относительно текущего STG-батча.
Это сверка ключей, а не простое сравнение числа строк.
- [`sql/stg/airplanes_dq.sql`](../../sql/stg/airplanes_dq.sql) сравнивает число
строк во внешней `stg.airplanes_ext` и в текущем батче `stg.airplanes`.
- [`sql/dds/fact_flight_sales_dq.sql`](../../sql/dds/fact_flight_sales_dq.sql)
требует равенства числа строк факта и `ods.segments`, потому что зерно факта —
один сегмент билета.
## `NULL` — не всегда ошибка
В [`fact_flight_sales_load.sql`](../../sql/dds/fact_flight_sales_load.sql)
измерения присоединяются через `LEFT JOIN`. Факт может прийти раньше измерения,
поэтому `route_sk`, `airplane_sk` или `passenger_sk` временно бывают `NULL`. На
ветке `main` это также ожидаемо, пока студент не реализовал эти измерения. Такой
`NULL` сохраняет строку факта и не должен сам по себе останавливать пайплайн.
`seat_no` тоже может быть `NULL`: у билета уже есть сегмент, но записи
`boarding_passes` ещё нет. Обязательный ключ, напротив, должен быть заполнен.
Например, `NULL` в `airport_sk` измерения или в `(ticket_no, flight_id)` факта
нарушает контракт таблицы.
Архитектурное решение о том, где живут DQ-проверки, зафиксировано в разделе
[«Ключевые договорённости»](../design/db_schema.md#ключевые-договорённости).
+603
View File
@@ -0,0 +1,603 @@
# План отладки эталонного пайплайна
> Цель: убедиться, что эталонный вертикальный срез (STG→ODS→DDS→DM) работает
> корректно при разных сценариях. Найти и исправить баги ДО того, как начнём
> готовить ветку main для студентов.
>
> Аудитория документа: AI-агент (Sonnet) или человек, выполняющий отладку.
>
> **Ветка `main`:** на main часть загрузок — заглушки (`SELECT 1;`).
> Пустые таблицы на main: `ods.airplanes`, `ods.seats`,
> `dds.dim_airplanes`, `dds.dim_passengers`, `dds.dim_routes`,
> `dm.route_performance`, `dm.passenger_loyalty`, `dm.airport_traffic`, `dm.monthly_overview`.
> Ожидания ниже написаны для ветки `solution` (полная реализация).
>
> Зависимость: перед запуском этого плана нужно починить bookings-db
> (см. `docs/reference/bookings_db_issues.md`).
> Классы проверок для разбора ошибок: [`dq_taxonomy.md`](dq_taxonomy.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: эталонные таблицы не пустые (на main ods.airplanes и ods.seats пусты by design)
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 таблиц не пустые (на main dim_airplanes, dim_passengers, dim_routes пусты by design)
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 витрин не пустые (на main только sales_report непуста, остальные 4 — заглушки)
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;
```
**Ожидание (solution):** ВСЕ таблицы > 0 строк. Если какая-то пустая — это баг.
**Ожидание (main):** Эталонные таблицы > 0; студенческие (см. список выше) = 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` при старте.
Подробнее: см. ветку `solution` (`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).';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dds.dim_airplanes
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+4
View File
@@ -0,0 +1,4 @@
-- TODO: реализуйте загрузку dds.dim_airplanes (см. ТЗ в docs/assignment/analyst_spec.md)
-- Паттерн: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+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).';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dds.dim_passengers
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+4
View File
@@ -0,0 +1,4 @@
-- TODO: реализуйте загрузку dds.dim_passengers (см. ТЗ в docs/assignment/analyst_spec.md)
-- Паттерн: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+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).';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dds.dim_routes (SCD2)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+4
View File
@@ -0,0 +1,4 @@
-- TODO: реализуйте загрузку dds.dim_routes (см. ТЗ в docs/assignment/analyst_spec.md)
-- Паттерн: SCD2 с hashdiff — самый нетривиальный паттерн в проекте.
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+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).';
+169
View File
@@ -0,0 +1,169 @@
-- 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_airport BIGINT;
v_null_student 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 будет NULL, пока вы не реализуете dim_passengers.
-- После реализации: TRUNCATE dds.fact_flight_sales → перезагрузка → все SK заполнены.
-- Полную версию DQ (с блокировкой) см. в ветке solution.
SELECT COUNT(*)
INTO v_null_passenger
FROM dds.fact_flight_sales
WHERE passenger_sk IS NULL;
IF v_null_passenger <> 0 THEN
RAISE NOTICE
'DQ INFO: студенческий SK (passenger) NULL: %. После реализации dim_passengers: TRUNCATE fact → перезагрузка.',
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;
-- 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;
-- route_sk и airplane_sk будут NULL, пока вы не реализуете dim_routes.
-- Не блокируем pipeline.
-- Полную версию DQ (с блокировкой) см. в ветке solution.
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;
-- 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 приведет к двойному счету!';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dm.airport_traffic
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте загрузку витрины dm.airport_traffic (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+40
View File
@@ -0,0 +1,40 @@
-- DDL для витрины dm.monthly_overview (Помесячная сводка).
--
-- Учебные цели:
-- 1. Двухуровневая агрегация (до рейса, затем до месяца) для точного расчета
-- средних показателей (load factor), избегая парадокса Симпсона.
-- 2. Выбор ключа распределения (Distribution Key):
-- Мы используем airplane_sk, а НЕ (year, month). Распределение по датам в MPP —
-- это антипаттерн, приводящий к Data Skew (весь месяц на одном сегменте).
CREATE TABLE IF NOT EXISTS dm.monthly_overview (
-- Зерно: Месяц и самолет
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 NOT NULL,
-- Метрики
total_flights INTEGER NOT NULL DEFAULT 0,
total_tickets INTEGER NOT NULL DEFAULT 0,
total_boarded INTEGER NOT NULL DEFAULT 0,
total_revenue NUMERIC(15,2) NOT NULL DEFAULT 0,
avg_ticket_price NUMERIC(10,2),
avg_load_factor NUMERIC(5,4), -- Точный load factor (сначала по рейсу, потом среднее)
unique_routes INTEGER NOT NULL DEFAULT 0,
unique_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 (airplane_sk); -- Избегаем распределения по (year, month)
COMMENT ON TABLE dm.monthly_overview IS 'Витрина: помесячная аналитика с точным load_factor (Двухуровневая агрегация, Heap)';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dm.monthly_overview
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте загрузку витрины dm.monthly_overview (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+41
View File
@@ -0,0 +1,41 @@
-- DDL для витрины dm.passenger_loyalty (Лояльность пассажиров).
--
-- Учебные цели:
-- 1. Выбор зерна для SCD1-измерений:
-- В SCD2-витринах (напр. route_performance) зерно — бизнес-ключ (BK), т.к. версий много.
-- В SCD1 (dim_passengers) BK и SK связаны 1:1, поэтому зерном выступает суррогатный ключ (SK).
-- Это упрощает JOIN с фактами и ускоряет запросы в Greenplum.
-- 2. Использование Heap-таблицы для UPSERT:
-- В отличие от AO Column, Heap поддерживает эффективный UPDATE, что критично для
-- накопительных витрин с большим количеством строк.
-- 3. Служебные поля жизненного цикла:
-- Т.к. мы будем обновлять метрики пассажиров, нам нужны и created_at, и updated_at.
CREATE TABLE IF NOT EXISTS dm.passenger_loyalty (
-- Ключ: суррогатный ключ пассажира (SCD1 гарантирует 1:1 к бизнес-ключу)
passenger_sk INTEGER NOT NULL,
passenger_bk TEXT NOT NULL,
passenger_name TEXT NOT NULL,
-- Метрики лояльности
total_bookings INTEGER NOT NULL, -- кол-во бронирований (book_ref)
total_flights INTEGER NOT NULL, -- кол-во перелетов
total_boarded INTEGER NOT NULL, -- кол-во успешных посадок
total_spent NUMERIC(15,2) NOT NULL,
avg_ticket_price NUMERIC(10,2),
favorite_fare_conditions TEXT, -- самый частый класс обслуживания
unique_routes INTEGER NOT NULL, -- кол-во уникальных маршрутов
first_flight_date DATE,
last_flight_date DATE,
days_as_customer 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) -- Явно указываем Heap для поддержки эффективного UPDATE
DISTRIBUTED BY (passenger_sk);
COMMENT ON TABLE dm.passenger_loyalty IS 'Витрина: лояльность и активность пассажиров (Incremental UPSERT, Heap)';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dm.passenger_loyalty
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте загрузку витрины dm.passenger_loyalty (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+48
View File
@@ -0,0 +1,48 @@
-- DDL для витрины dm.route_performance (Эффективность маршрутов).
--
-- Учебные цели:
-- 1. Демонстрация AO Column Store (Append-Only Columnar Storage).
-- В Greenplum формат AO Column идеален для аналитики (сжатие, чтение только нужных колонок).
-- 2. Демонстрация стратегии Full Rebuild (TRUNCATE + INSERT).
-- Для небольших справочных витрин это проще и надёжнее, чем сложный инкремент.
-- 3. Выбор служебных полей:
-- Для Full Rebuild таблиц created_at/updated_at не имеют смысла, т.к. строки
-- каждый раз пересоздаются. Достаточно _load_ts.
CREATE TABLE IF NOT EXISTS dm.route_performance (
-- Ключ: бизнес-код маршрута (напр. 'SVO-LED')
route_bk TEXT NOT NULL,
-- SK текущей (актуальной) версии маршрута для связи с измерениями
route_sk INTEGER NOT NULL,
-- Денормализованные атрибуты (из актуальной версии dim_routes)
departure_airport_bk TEXT NOT NULL,
departure_city TEXT NOT NULL,
arrival_airport_bk TEXT NOT NULL,
arrival_city TEXT NOT NULL,
airplane_bk TEXT NOT NULL,
airplane_model TEXT NOT NULL,
total_seats INTEGER NOT NULL,
-- Метрики (агрегированы по всем версиям данного маршрута)
total_flights INTEGER NOT NULL,
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), -- средняя заполняемость кресел
first_flight_date DATE,
last_flight_date DATE,
-- Служебные поля.
-- created_at/updated_at здесь не нужны: при Full Rebuild все строки пересоздаются,
-- поэтому _load_ts достаточно для отслеживания момента загрузки.
_load_id TEXT NOT NULL,
_load_ts TIMESTAMP NOT NULL DEFAULT now()
)
WITH (appendonly=true, orientation=column, compresstype=zstd, compresslevel=1)
DISTRIBUTED BY (route_bk);
COMMENT ON TABLE dm.route_performance IS 'Витрина: эффективность авиамаршрутов (Full Rebuild, AO Column, zstd)';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dm.route_performance
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте загрузку витрины dm.route_performance (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+65
View File
@@ -0,0 +1,65 @@
-- DDL для DM-слоя: витрина sales_report.
--
-- Бизнес-вопрос: "Какова выручка, кол-во билетов и boarding rate
-- по направлениям/тарифам за каждый день?"
--
-- Паттерны для студентов:
-- - Денормализация измерений (города, аэропорты, тарифы) для удобства аналитики
-- - Служебные поля календаря (day_of_week, day_name, is_weekend)
--
-- ВНИМАНИЕ (Антипаттерн распределения):
-- Никогда не распределяйте таблицы по дате (DISTRIBUTED BY flight_date) в MPP-системах!
-- 1. Load Skew: При инкрементальной загрузке весь батч за один день запишется на ОДИН сегмент,
-- а остальные будут простаивать. Кластер превратится в одиночный сервер.
-- 2. Processing Skew: Запросы аналитиков за конкретный день будут читаться только с одного сегмента.
--
-- ПРАВИЛЬНЫЙ ВЫБОР: Распределение по полям с высокой кардинальностью (направления).
-- В нашем случае это комбинация departure_airport_sk и arrival_airport_sk.
CREATE SCHEMA IF NOT EXISTS dm;
-- Тип таблицы: Heap (стандартная).
-- Обоснование: Необходим row-level UPDATE для реализации инкрементального UPSERT по HWM.
-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы.
CREATE TABLE IF NOT EXISTS dm.sales_report (
-- Ключ (зерно витрины)
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
-- Служебные поля (канон из naming_conventions.md)
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 (departure_airport_sk, arrival_airport_sk);
-- Комментарии для документирования
COMMENT ON TABLE dm.sales_report IS 'Витрина продаж: выручка, билеты и boarding rate по направлениям/тарифам/дням.';
COMMENT ON COLUMN dm.sales_report.boarding_rate IS
'Доля пассажиров, прошедших посадку (passengers_boarded / tickets_sold)';
+144
View File
@@ -0,0 +1,144 @@
-- DQ для DM витрины sales_report.
--
-- Проверки:
-- 1. Таблица не пуста (если источник за затронутые батчем дни не пуст)
-- 2. Нет дублей по составному ключу
-- 3. Бизнес-инварианты: tickets_sold >= passengers_boarded, boarding_rate BETWEEN 0 AND 1
-- 4. Обязательные поля не NULL
--
-- ВАЖНО (Паттерн Data Quality):
-- Проверки выполняются ИНКРЕМЕНТАЛЬНО. Мы спрашиваем саму витрину, какие даты
-- были обновлены текущим запуском DAG-а (по _load_id = run_id), и валидируем
-- только эти даты. Это корректно, потому что на шаге load мы записали
-- текущий run_id DM-DAG-а в обновлённые строки витрины.
DO $$
DECLARE
v_row_count BIGINT;
v_src_count BIGINT;
v_dup_count BIGINT;
v_invalid_boarding BIGINT;
v_null_required BIGINT;
BEGIN
-- Создаем временную таблицу с датами, которые МЫ обновили в этом запуске DAG-а.
-- Спрашиваем саму витрину (не DDS!), потому что на шаге load мы записали
-- _load_id = '{{ run_id }}' текущего DM-DAG-а в обновлённые строки.
CREATE TEMP TABLE tmp_dq_affected_dates ON COMMIT DROP AS
SELECT DISTINCT flight_date AS date_actual
FROM dm.sales_report
WHERE _load_id = '{{ run_id }}';
-- Если батч пуст (ничего не обновлялось), нам нечего валидировать.
IF NOT EXISTS (SELECT 1 FROM tmp_dq_affected_dates) THEN
RAISE NOTICE 'DQ PASSED: Витрина не обновлялась в этом запуске (%). Новых данных в DDS нет.', '{{ run_id }}';
RETURN;
END IF;
-- Проверка 1: Таблица не пуста при непустом источнике (для затронутых дат)
SELECT COUNT(*)
INTO v_src_count
FROM dds.fact_flight_sales AS f
JOIN dds.dim_calendar AS cal
ON cal.calendar_sk = f.calendar_sk
WHERE cal.date_actual IN (SELECT date_actual FROM tmp_dq_affected_dates);
SELECT COUNT(*)
INTO v_row_count
FROM dm.sales_report
WHERE flight_date IN (SELECT date_actual FROM tmp_dq_affected_dates);
IF v_src_count > 0 AND v_row_count = 0 THEN
RAISE EXCEPTION
'DQ FAILED: dm.sales_report пуста для дат батча при непустом источнике (% строк в fact_flight_sales)',
v_src_count;
END IF;
RAISE NOTICE 'DQ INFO: dm.sales_report содержит % строк для дат текущего батча', v_row_count;
-- Проверка 2: Нет дублей по составному ключу (в рамках затронутых дат)
SELECT COUNT(*)
INTO v_dup_count
FROM (
SELECT flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk
FROM dm.sales_report
WHERE flight_date IN (SELECT date_actual FROM tmp_dq_affected_dates)
GROUP BY flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk
HAVING COUNT(*) > 1
) AS dups;
IF v_dup_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: Найдено % дублирующихся комбинаций ключа в dm.sales_report',
v_dup_count;
END IF;
RAISE NOTICE 'DQ PASSED: Дублей по составному ключу для дат батча нет';
-- Проверка 3: Бизнес-инварианты (в рамках затронутых дат)
-- tickets_sold >= passengers_boarded
SELECT COUNT(*)
INTO v_invalid_boarding
FROM dm.sales_report
WHERE flight_date IN (SELECT date_actual FROM tmp_dq_affected_dates)
AND tickets_sold < passengers_boarded;
IF v_invalid_boarding <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: % строк с tickets_sold < passengers_boarded',
v_invalid_boarding;
END IF;
-- boarding_rate BETWEEN 0 AND 1
SELECT COUNT(*)
INTO v_invalid_boarding
FROM dm.sales_report
WHERE flight_date IN (SELECT date_actual FROM tmp_dq_affected_dates)
AND (boarding_rate < 0 OR boarding_rate > 1);
IF v_invalid_boarding <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: % строк с boarding_rate вне диапазона [0, 1]',
v_invalid_boarding;
END IF;
-- total_revenue >= 0
SELECT COUNT(*)
INTO v_invalid_boarding
FROM dm.sales_report
WHERE flight_date IN (SELECT date_actual FROM tmp_dq_affected_dates)
AND total_revenue < 0;
IF v_invalid_boarding <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: % строк с отрицательной total_revenue',
v_invalid_boarding;
END IF;
RAISE NOTICE 'DQ PASSED: Бизнес-инварианты соблюдены';
-- Проверка 4: Обязательные поля не NULL (в рамках затронутых дат)
SELECT COUNT(*)
INTO v_null_required
FROM dm.sales_report
WHERE flight_date IN (SELECT date_actual FROM tmp_dq_affected_dates)
AND (
departure_airport_sk IS NULL
OR arrival_airport_sk IS NULL
OR tariff_sk IS NULL
OR tickets_sold IS NULL
OR total_revenue IS NULL
OR boarding_rate IS NULL
);
IF v_null_required <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: % строк с NULL в обязательных полях',
v_null_required;
END IF;
RAISE NOTICE 'DQ PASSED: Обязательные поля заполнены';
-- Итог
RAISE NOTICE 'DQ COMPLETE: dm.sales_report прошла все проверки для текущего батча';
END $$;
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more